@bojackduy/opencode-learn 1.2.5 → 1.3.0

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
@@ -115,6 +115,8 @@ quiz_batch(quizzes=[{question:"…", options:[…], correctAnswer:["Red","Blue"]
115
115
  ```
116
116
  Single → TUI popup `QuizDialog` (single/multi + `I don't know` + note). Batch → deck `1/3→3/3` `QuizBatchDialog` (same 4-state `hit/miss/false-alarm/correct-rejection` solid `bg` inverted). Both durable `pendingDir` `.opencode/learn-pending` — kill `opencode` mid-popup → re-show on restart.
117
117
 
118
+ Quiz dialogs are asynchronous. The agent must call `quiz` or `quiz_batch` alone, end that assistant turn once the dialog is displayed, and continue only after the TUI injects the learner's answer.
119
+
118
120
  Open forks: native `question` (single/multi `Other`).
119
121
 
120
122
  **Visual — one correct picture**
package/dist/server.js CHANGED
@@ -529,24 +529,31 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
529
529
  slog("watchAndInject no sessionID", id);
530
530
  return;
531
531
  }
532
+ activeWatchers.get(id)?.();
532
533
  const dir = pendingDir(directory);
533
534
  const respPath = path.join(dir, `response-${id}.json`);
534
535
  const claimPath = path.join(dir, `response-${id}.claim-${process.pid}.json`);
535
536
  const pendingCandidates = [path.join(dir, `quiz-${id}.json`), path.join(dir, `quiz_batch-${id}.json`)];
537
+ let watcher;
538
+ let pollTimer;
539
+ let closed = false;
536
540
  const closeWatcher = () => {
537
- const w = activeWatchers.get(id);
538
- if (w) {
539
- try {
540
- w.close();
541
- } catch {}
541
+ if (closed)
542
+ return;
543
+ closed = true;
544
+ try {
545
+ watcher?.close();
546
+ } catch {}
547
+ if (pollTimer)
548
+ clearInterval(pollTimer);
549
+ if (activeWatchers.get(id) === closeWatcher)
542
550
  activeWatchers.delete(id);
543
- }
544
551
  };
552
+ activeWatchers.set(id, closeWatcher);
545
553
  const fire = async (attempt = 0) => {
546
554
  try {
547
555
  fs.renameSync(respPath, claimPath);
548
556
  } catch {
549
- closeWatcher();
550
557
  return;
551
558
  }
552
559
  let data;
@@ -626,13 +633,17 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
626
633
  }
627
634
  if (!ok) {
628
635
  slog("watchAndInject inject FAILED, preserved", id, effectiveSessionID, String(firstErr).slice(0, 200));
636
+ closeWatcher();
629
637
  try {
630
- fs.renameSync(claimPath, path.join(dir, `response-${id}.failed-${Date.now()}.json`));
631
- } catch {}
638
+ fs.renameSync(claimPath, respPath);
639
+ } catch {
640
+ try {
641
+ fs.renameSync(claimPath, path.join(dir, `response-${id}.failed-${Date.now()}.json`));
642
+ } catch {}
643
+ }
632
644
  try {
633
645
  await client.app.log({ body: { service: "learn", level: "error", message: `inject FAILED for ${effectiveSessionID} (orig ${sessionID})`, extra: { id } } });
634
646
  } catch {}
635
- closeWatcher();
636
647
  return;
637
648
  }
638
649
  try {
@@ -649,12 +660,19 @@ function watchAndInject(client, directory, id, sessionID, buildText) {
649
660
  } catch {}
650
661
  };
651
662
  try {
652
- const w = fs.watch(dir, () => {
663
+ watcher = fs.watch(dir, () => {
653
664
  fire();
654
665
  });
655
- w.on("error", () => {});
656
- activeWatchers.set(id, w);
666
+ watcher.on("error", () => {});
657
667
  } catch {}
668
+ pollTimer = setInterval(() => {
669
+ if (fs.existsSync(respPath)) {
670
+ fire();
671
+ return;
672
+ }
673
+ if (!pendingCandidates.some((p) => fs.existsSync(p)) && !fs.existsSync(claimPath))
674
+ closeWatcher();
675
+ }, 500);
658
676
  if (fs.existsSync(respPath)) {
659
677
  slog("watchAndInject fast-path", id);
660
678
  fire();
@@ -1164,9 +1182,9 @@ Explanation: ${j.explanation}${note}`;
1164
1182
  },
1165
1183
  tool: {
1166
1184
  quiz: tool({
1167
- description: "Ask the user a GRADED question with a known correct answer, then grade and give feedback. Unlike the native `question` tool (which collects preferences with no right answer), `quiz` has a correct answer, marks selection right/wrong, reveals correct answer, and shows explanation. Use to assess understanding before teaching and for retrieval practice after. Options-only: single/multi-select plus auto 'I don't know'. No free-text. For non-graded questions use the native `question` tool.",
1185
+ description: "Ask the user a GRADED question with a known correct answer, then grade and give feedback. Unlike the native `question` tool (which collects preferences with no right answer), `quiz` has a correct answer, marks selection right/wrong, reveals the correct answer, and shows an explanation. The TUI interaction is asynchronous, so call `quiz` ALONE in an assistant turn: never call it in parallel or in the same response with native `question`, another `quiz`, `quiz_batch`, or any other user-input tool. When the result says displayed/waiting, end the turn immediately; the answer will be injected as a new turn. Only call native `question` after `quiz` if its result explicitly requests the no-TUI fallback. Use to assess understanding before teaching and for retrieval practice after. Options-only: single/multi-select plus auto 'I don't know'. No free-text. For non-graded questions use the native `question` tool.",
1168
1186
  args: {
1169
- question: tool.schema.string().describe("Single quiz question to ask. One per call."),
1187
+ question: tool.schema.string().describe("Single quiz question to ask. Call this tool alone; do not combine it with another user-input tool in the same turn."),
1170
1188
  details: tool.schema.string().optional().describe("Extra context shown under question."),
1171
1189
  options: tool.schema.array(tool.schema.object({
1172
1190
  label: tool.schema.string().describe("Display label"),
@@ -1264,7 +1282,7 @@ Explanation: ${eFixed}${note}`;
1264
1282
  }
1265
1283
  }
1266
1284
  if (tuiAlive) {
1267
- return `[quiz displayed in TUI \u2014 waiting for your answer in the popup. I'll continue once you respond.]`;
1285
+ return `[quiz displayed in TUI - waiting for the user's answer in the popup. STOP this assistant turn now. Do not call \`question\`, another tool, or ask another question in text. The answer will be injected as a new turn.]`;
1268
1286
  }
1269
1287
  const isTTY = process.stdin?.isTTY && process.stdout?.isTTY;
1270
1288
  const insideOpencode = !!process.env?.OPENCODE || !!process.env?.OPENCODE_TUI;
@@ -1343,7 +1361,7 @@ Explanation: ${eFixed}`;
1343
1361
  }
1344
1362
  }),
1345
1363
  quiz_batch: tool({
1346
- description: "Batch version of quiz \u2014 shows 2-8 graded questions as a deck (Quiz 1/3 \u2192 2/3 \u2192 3/3) in one beautiful TUI, then one combined inject. Use when you want multiple probes without separate tool calls. Each entry has same schema as quiz.",
1364
+ description: "Batch version of quiz - shows 2-8 graded questions as a deck (Quiz 1/3 to 2/3 to 3/3) in one TUI, then injects one combined answer. The TUI interaction is asynchronous, so call `quiz_batch` ALONE in an assistant turn: never call it in parallel or in the same response with native `question`, `quiz`, another `quiz_batch`, or any other user-input tool. When the result says displayed/waiting, end the turn immediately; the answers will be injected as a new turn. Use when you want multiple non-adaptive checks in one deck. Each entry has the same schema as quiz.",
1347
1365
  args: {
1348
1366
  quizzes: tool.schema.array(tool.schema.object({
1349
1367
  question: tool.schema.string(),
@@ -1453,9 +1471,9 @@ Explanation: ${eFixed}`;
1453
1471
  });
1454
1472
  slog("quiz_batch watchAndInject armed", id, "alive", isAlive);
1455
1473
  if (isAlive)
1456
- return `[quiz batch displayed in TUI \u2014 ${normalized.length} quizzes as deck Quiz 1/${normalized.length} \u2192 ${normalized.length}/${normalized.length}. Answer all, then one combined inject.]`;
1474
+ return `[quiz batch displayed in TUI - ${normalized.length} quizzes as deck Quiz 1/${normalized.length} to ${normalized.length}/${normalized.length}. STOP this assistant turn now. Do not call \`question\`, another tool, or ask another question in text. The answers will be injected as a new turn.]`;
1457
1475
  else
1458
- return `[quiz batch displayed durably \u2014 TUI not alive yet, will appear on restart. Answer all, then one combined inject.]`;
1476
+ return `[quiz batch stored durably - TUI not alive yet, so it will appear on restart. STOP this assistant turn now. Do not call \`question\`, another tool, or ask another question in text. The answers will be injected after the user completes the deck.]`;
1459
1477
  }
1460
1478
  }),
1461
1479
  md_log: tool({
package/dist/tui.js CHANGED
@@ -2628,6 +2628,14 @@ var tui = async (api) => {
2628
2628
  } catch {}
2629
2629
  return null;
2630
2630
  };
2631
+ const hasAnswerArtifact = (id) => {
2632
+ const response = `response-${id}`;
2633
+ try {
2634
+ return fs.readdirSync(pendingDir).some((file) => file === `${response}.json` || file.startsWith(`${response}.claim-`));
2635
+ } catch {
2636
+ return false;
2637
+ }
2638
+ };
2631
2639
  const processPending = () => {
2632
2640
  const curSid = getCurrentSessionID();
2633
2641
  if (!curSid)
@@ -2653,13 +2661,7 @@ var tui = async (api) => {
2653
2661
  } catch {
2654
2662
  return null;
2655
2663
  }
2656
- }).filter(Boolean).filter((x) => {
2657
- try {
2658
- return !fs.existsSync(path.join(pendingDir, `response-${x.j.id}.json`));
2659
- } catch {
2660
- return true;
2661
- }
2662
- });
2664
+ }).filter(Boolean).filter((x) => !hasAnswerArtifact(x.j.id));
2663
2665
  const pick = matching.find((x) => x.j.sessionID === curSid) || matching.find((x) => !x.j.sessionID);
2664
2666
  if (!pick)
2665
2667
  return;
@@ -2797,9 +2799,6 @@ var tui = async (api) => {
2797
2799
  }
2798
2800
  }
2799
2801
  } catch {}
2800
- try {
2801
- fs.unlinkSync(full);
2802
- } catch {}
2803
2802
  api.ui.dialog.clear();
2804
2803
  currentBySession.delete(curSid);
2805
2804
  setTimeout(processPending, 150);
@@ -2859,9 +2858,6 @@ var tui = async (api) => {
2859
2858
  } catch {}
2860
2859
  }
2861
2860
  } catch {}
2862
- try {
2863
- fs.unlinkSync(full);
2864
- } catch {}
2865
2861
  api.ui.dialog.clear();
2866
2862
  currentBySession.delete(curSid);
2867
2863
  setTimeout(processPending, 150);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@bojackduy/opencode-learn",
4
- "version": "1.2.5",
4
+ "version": "1.3.0",
5
5
  "description": "Pi learn system for OpenCode — Socratic teaching, graded quiz, Obsidian md_log, and visual makers. Port of amosblomqvist/learn (video: How I Use AI to Learn Things) to OpenCode.",
6
6
  "type": "module",
7
7
  "license": "AGPL-3.0-or-later",
@@ -778,6 +778,15 @@ export const tui: TuiPlugin = async (api) => {
778
778
  return null
779
779
  }
780
780
 
781
+ const hasAnswerArtifact = (id: string): boolean => {
782
+ const response = `response-${id}`
783
+ try {
784
+ return fs.readdirSync(pendingDir).some((file) => file === `${response}.json` || file.startsWith(`${response}.claim-`))
785
+ } catch {
786
+ return false
787
+ }
788
+ }
789
+
781
790
  const processPending = () => {
782
791
  const curSid = getCurrentSessionID()
783
792
  if (!curSid) return
@@ -788,9 +797,7 @@ export const tui: TuiPlugin = async (api) => {
788
797
  try { files = fs.readdirSync(pendingDir).filter(f => f.endsWith(".json") && !f.startsWith("response-") && !f.startsWith(".") && !f.startsWith("classify")).sort() } catch { return }
789
798
  // Session-distinct: only show pending for current session.
790
799
  // Skip answered-pending (a response file exists, server is consuming): prevents re-popup after answer.
791
- const matching = files.map(f => { try { const j = JSON.parse(fs.readFileSync(path.join(pendingDir, f), "utf8")) as any; return { f, j } } catch { return null } }).filter(Boolean).filter(x => {
792
- try { return !fs.existsSync(path.join(pendingDir, `response-${x!.j.id}.json`)) } catch { return true }
793
- }) as Array<{f: string, j: any}>
800
+ const matching = files.map(f => { try { const j = JSON.parse(fs.readFileSync(path.join(pendingDir, f), "utf8")) as any; return { f, j } } catch { return null } }).filter(Boolean).filter(x => !hasAnswerArtifact(x!.j.id)) as Array<{f: string, j: any}>
794
801
  const pick = matching.find(x => x.j.sessionID === curSid) || matching.find(x => !x.j.sessionID)
795
802
  if (!pick) return
796
803
  const file = pick.f
@@ -875,9 +882,8 @@ export const tui: TuiPlugin = async (api) => {
875
882
  }
876
883
  }
877
884
  } catch {}
878
- // TUI owns dialog lifecycle always delete pending + close immediately so Enter never appears to "do nothing",
879
- // regardless of whether another (possibly stale/old-code) opencode process is also running.
880
- try { fs.unlinkSync(full) } catch {}
885
+ // Keep the pending payload until the server confirms injection. It is the recovery context
886
+ // needed to rebuild the prompt if either process exits after the answer is written.
881
887
  api.ui.dialog.clear()
882
888
  currentBySession.delete(curSid)
883
889
  setTimeout(processPending, 150)
@@ -911,7 +917,7 @@ export const tui: TuiPlugin = async (api) => {
911
917
  } catch {}
912
918
  }
913
919
  } catch {}
914
- try { fs.unlinkSync(full) } catch {}
920
+ // Keep the pending payload until the server consumes the cancellation response.
915
921
  api.ui.dialog.clear()
916
922
  currentBySession.delete(curSid)
917
923
  setTimeout(processPending, 150)
package/plugins/learn.ts CHANGED
@@ -476,18 +476,29 @@ async function waitForResponse(directory: string, id: string, abort: AbortSignal
476
476
  }
477
477
 
478
478
  // Server-side inject (loopd pattern: host-adapter.ts:100 promptAsync + path.id + body.parts)
479
- const activeWatchers = new Map<string, fs.FSWatcher>()
479
+ const activeWatchers = new Map<string, () => void>()
480
480
  function watchAndInject(client: any, directory: string, id: string, sessionID: string, buildText: (result: any) => string) {
481
481
  slog("watchAndInject start", id, sessionID)
482
482
  if (!sessionID) { slog("watchAndInject no sessionID", id); return }
483
+ activeWatchers.get(id)?.()
483
484
  const dir = pendingDir(directory)
484
485
  const respPath = path.join(dir, `response-${id}.json`)
485
486
  const claimPath = path.join(dir, `response-${id}.claim-${process.pid}.json`)
486
487
  const pendingCandidates = [path.join(dir, `quiz-${id}.json`), path.join(dir, `quiz_batch-${id}.json`)]
487
- const closeWatcher = () => { const w = activeWatchers.get(id); if (w) { try { w.close() } catch {}; activeWatchers.delete(id) } }
488
+ let watcher: fs.FSWatcher | undefined
489
+ let pollTimer: ReturnType<typeof setInterval> | undefined
490
+ let closed = false
491
+ const closeWatcher = () => {
492
+ if (closed) return
493
+ closed = true
494
+ try { watcher?.close() } catch {}
495
+ if (pollTimer) clearInterval(pollTimer)
496
+ if (activeWatchers.get(id) === closeWatcher) activeWatchers.delete(id)
497
+ }
498
+ activeWatchers.set(id, closeWatcher)
488
499
  const fire = async (attempt = 0): Promise<void> => {
489
500
  // Atomic single-consumer claim: exactly one process proceeds (fixes double-inject across processes)
490
- try { fs.renameSync(respPath, claimPath) } catch { closeWatcher(); return } // already claimed/consumed stand down
501
+ try { fs.renameSync(respPath, claimPath) } catch { return } // no answer yet, or another process claimed it
491
502
  let data: any
492
503
  try {
493
504
  data = JSON.parse(fs.readFileSync(claimPath, "utf8"))
@@ -547,11 +558,13 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
547
558
  }
548
559
  if (!ok) {
549
560
  slog("watchAndInject inject FAILED, preserved", id, effectiveSessionID, String(firstErr).slice(0,200))
550
- try { fs.renameSync(claimPath, path.join(dir, `response-${id}.failed-${Date.now()}.json`)) } catch {}
561
+ closeWatcher()
562
+ try { fs.renameSync(claimPath, respPath) } catch {
563
+ try { fs.renameSync(claimPath, path.join(dir, `response-${id}.failed-${Date.now()}.json`)) } catch {}
564
+ }
551
565
  try {
552
566
  await client.app.log({ body: { service: "learn", level: "error", message: `inject FAILED for ${effectiveSessionID} (orig ${sessionID})`, extra: { id } } })
553
567
  } catch {}
554
- closeWatcher()
555
568
  return
556
569
  }
557
570
  // Success: consume claim + defensively try pending too (TUI is the primary owner of pending deletion)
@@ -567,10 +580,15 @@ function watchAndInject(client: any, directory: string, id: string, sessionID: s
567
580
  // Fire on ANY directory event, not just exact filename match — atomic tmp+rename writes can report
568
581
  // a different filename (or no filename) on some fs.watch backends (notably macOS FSEvents). The claim
569
582
  // rename inside fire() makes this safe to call spuriously: if respPath doesn't exist yet, it just no-ops.
570
- const w = fs.watch(dir, () => { void fire() })
571
- w.on("error", () => {})
572
- activeWatchers.set(id, w)
583
+ watcher = fs.watch(dir, () => { void fire() })
584
+ watcher.on("error", () => {})
573
585
  } catch {}
586
+ // fs.watch is lossy by design. Polling keeps persisted answers moving after a TUI/server restart
587
+ // even when the filesystem event is dropped.
588
+ pollTimer = setInterval(() => {
589
+ if (fs.existsSync(respPath)) { void fire(); return }
590
+ if (!pendingCandidates.some((p) => fs.existsSync(p)) && !fs.existsSync(claimPath)) closeWatcher()
591
+ }, 500)
574
592
  // Recheck after arming to close the event gap (claim makes double-fire safe)
575
593
  if (fs.existsSync(respPath)) { slog("watchAndInject fast-path", id); void fire(); return }
576
594
  }
@@ -1054,9 +1072,9 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
1054
1072
  tool: {
1055
1073
  // ── quiz: graded question ────────────────────────────────────────
1056
1074
  quiz: tool({
1057
- description: "Ask the user a GRADED question with a known correct answer, then grade and give feedback. Unlike the native `question` tool (which collects preferences with no right answer), `quiz` has a correct answer, marks selection right/wrong, reveals correct answer, and shows explanation. Use to assess understanding before teaching and for retrieval practice after. Options-only: single/multi-select plus auto 'I don't know'. No free-text. For non-graded questions use the native `question` tool.",
1075
+ description: "Ask the user a GRADED question with a known correct answer, then grade and give feedback. Unlike the native `question` tool (which collects preferences with no right answer), `quiz` has a correct answer, marks selection right/wrong, reveals the correct answer, and shows an explanation. The TUI interaction is asynchronous, so call `quiz` ALONE in an assistant turn: never call it in parallel or in the same response with native `question`, another `quiz`, `quiz_batch`, or any other user-input tool. When the result says displayed/waiting, end the turn immediately; the answer will be injected as a new turn. Only call native `question` after `quiz` if its result explicitly requests the no-TUI fallback. Use to assess understanding before teaching and for retrieval practice after. Options-only: single/multi-select plus auto 'I don't know'. No free-text. For non-graded questions use the native `question` tool.",
1058
1076
  args: {
1059
- question: tool.schema.string().describe("Single quiz question to ask. One per call."),
1077
+ question: tool.schema.string().describe("Single quiz question to ask. Call this tool alone; do not combine it with another user-input tool in the same turn."),
1060
1078
  details: tool.schema.string().optional().describe("Extra context shown under question."),
1061
1079
  options: tool.schema.array(tool.schema.object({
1062
1080
  label: tool.schema.string().describe("Display label"),
@@ -1139,7 +1157,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
1139
1157
  }
1140
1158
  }
1141
1159
  if (tuiAlive) {
1142
- return `[quiz displayed in TUI waiting for your answer in the popup. I'll continue once you respond.]`
1160
+ return `[quiz displayed in TUI - waiting for the user's answer in the popup. STOP this assistant turn now. Do not call \`question\`, another tool, or ask another question in text. The answer will be injected as a new turn.]`
1143
1161
  }
1144
1162
  // ── Fallback: console TTY (NEVER inside opencode TUI — readline steals raw mode + mouse SGR `^[[<35;...M` and garbles alt-screen)
1145
1163
  // Inside opencode `OPENCODE=1` is always set, so skip readline and use instruction fallback that works with native `question` tool.
@@ -1199,7 +1217,7 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
1199
1217
 
1200
1218
  // ── quiz_batch: optional deck — quiz 1/3 → 2/3 → 3/3 in one dialog, one inject
1201
1219
  quiz_batch: tool({
1202
- description: "Batch version of quiz shows 2-8 graded questions as a deck (Quiz 1/3 2/3 3/3) in one beautiful TUI, then one combined inject. Use when you want multiple probes without separate tool calls. Each entry has same schema as quiz.",
1220
+ description: "Batch version of quiz - shows 2-8 graded questions as a deck (Quiz 1/3 to 2/3 to 3/3) in one TUI, then injects one combined answer. The TUI interaction is asynchronous, so call `quiz_batch` ALONE in an assistant turn: never call it in parallel or in the same response with native `question`, `quiz`, another `quiz_batch`, or any other user-input tool. When the result says displayed/waiting, end the turn immediately; the answers will be injected as a new turn. Use when you want multiple non-adaptive checks in one deck. Each entry has the same schema as quiz.",
1203
1221
  args: {
1204
1222
  quizzes: tool.schema.array(tool.schema.object({
1205
1223
  question: tool.schema.string(),
@@ -1289,8 +1307,8 @@ Return ONLY JSON: {"inferred":[2],"semanticCorrect":false,"reason":"...","isIDK"
1289
1307
  return `[quiz_batch answered] ${normalized.length} quizzes\n` + lines
1290
1308
  })
1291
1309
  slog("quiz_batch watchAndInject armed", id, "alive", isAlive)
1292
- if (isAlive) return `[quiz batch displayed in TUI ${normalized.length} quizzes as deck Quiz 1/${normalized.length} ${normalized.length}/${normalized.length}. Answer all, then one combined inject.]`
1293
- else return `[quiz batch displayed durably TUI not alive yet, will appear on restart. Answer all, then one combined inject.]`
1310
+ if (isAlive) return `[quiz batch displayed in TUI - ${normalized.length} quizzes as deck Quiz 1/${normalized.length} to ${normalized.length}/${normalized.length}. STOP this assistant turn now. Do not call \`question\`, another tool, or ask another question in text. The answers will be injected as a new turn.]`
1311
+ else return `[quiz batch stored durably - TUI not alive yet, so it will appear on restart. STOP this assistant turn now. Do not call \`question\`, another tool, or ask another question in text. The answers will be injected after the user completes the deck.]`
1294
1312
  }
1295
1313
  }),
1296
1314
 
@@ -72,6 +72,10 @@ The two principles are *how* you teach. This is *when* — the shape of a teachi
72
72
 
73
73
  **Accuracy is non-negotiable — verify, don't wing it from memory.** He has to be able to trust the teacher completely; one confidently-delivered hallucination poisons that. Working from memory alone is where LLMs invent things, so: **the moment you are even slightly unsure of any fact, name, date, formula, definition, or claim, stop and confirm it with a quick `researcher` subagent (via `task` with `subagent_type: "researcher"`) before you say it.** Pausing to verify is always acceptable — accuracy beats flow, every time. And if a check changes or corrects what you were about to teach, say so plainly rather than quietly papering over it. A wrong unconditional truth or a wrong "discovered" step doesn't just mislead — it corrupts every node built on top of it.
74
74
 
75
+ ### Interactive turns — one at a time
76
+
77
+ **Interactive turns are serialized.** `quiz` is asynchronous in the OpenCode TUI: it returns control to the agent while its popup is still waiting for the learner. Therefore, call `quiz` **alone** in an assistant turn. Never call it in parallel with native `question`, `quiz_batch`, another `quiz`, or any other user-input tool. When its result says the quiz is displayed/waiting, end the turn immediately without asking another question in prose; the learner's answer will be injected as a new turn. Apply the same rule to `quiz_batch`. Only use native `question` after a quiz call when the quiz result explicitly requests that fallback because the TUI is unavailable.
78
+
75
79
  ### Writing quiz options — a construction procedure (applies to every `quiz`)
76
80
 
77
81
  The tool already tells you to keep options even. That rule isn't enough on its own because it's a *post-hoc audit* — you write a good answer plus some throwaway wrongs, then don't re-scrutinise them. The tell is baked in before any check runs. So don't audit afterwards; **build the options so evenness is automatic**:
@@ -85,9 +89,11 @@ If, reading the finished set cold, you can still tell which is right without kno
85
89
 
86
90
  ### Phase 1 — Probe (never skip this)
87
91
 
88
- You can't teach into his zone of proximal development without knowing where its edges are, and you can't aim the teaching without knowing what he's actually reaching for. Two separate unknowns, two separate tools — keep the boundary clean:
92
+ You can't teach into his zone of proximal development without knowing where its edges are, and you can't aim the teaching without knowing what he's actually reaching for. Two separate unknowns, two separate tools, and separate turns — keep the boundary clean. Resolve the goal first so you know which prerequisite strands are relevant, then probe those strands. Never launch the goal question and a quiz together.
89
93
 
90
- **1a. His current level — use `quiz`. This is a mapping job, not a spot-check.** Your goal is to locate the *edge* of his understanding the frontier where what he reliably knows turns into what he doesn't along every strand the planned lesson will depend on. Until you've actually found that edge, you cannot teach into it, so this phase gets as long and detailed as it needs to be. There is no rush.
94
+ **1a. His learning goal — use native `question` only when needed.** Find out what he actually wants taught. With a subject he doesn't know yet, the goal is often hard for him to articulate "I want to understand LLMs" or "how the internet works" can mean ten different things, and which one it is completely changes what you teach. If his request already makes the desired outcome concrete, accept it and do not ask again. Otherwise, interrogate the vision until it's concrete. This has no right answer, so it's `question`, never `quiz`. Do not use `quiz` or `quiz_batch` for the goal goal has no correct answer. Wait for this answer before starting 1b.
95
+
96
+ **1b. His current level — use `quiz`. This is a mapping job, not a spot-check.** Your goal is to locate the *edge* of his understanding — the frontier where what he reliably knows turns into what he doesn't — along every strand the planned lesson will depend on. Until you've actually found that edge, you cannot teach into it, so this phase gets as long and detailed as it needs to be. There is no rush.
91
97
 
92
98
  **The edge is only located when it's bracketed.** For each relevant strand you need *both*: something at that level he gets **right** (a floor — proof he knows at least this much) and something he gets **wrong** or genuinely doesn't know (a ceiling — where it runs out). The edge sits between them. One side alone tells you almost nothing.
93
99
 
@@ -98,9 +104,7 @@ You can't teach into his zone of proximal development without knowing where its
98
104
 
99
105
  Do not advance to Phase 2 until, for each goal-relevant strand, you can state concretely both what he has and where it ends. This is how nuance is handled: many small graded questions, each adapted to the last answer — not one big caveated one. Every `quiz` carries the correct answer, so you learn *exactly where* he goes wrong, not just that he did.
100
106
 
101
- **Guardrail — one quiz at a time:** Call exactly **one** `quiz` per turn and wait for the user's answer (injected via the TUI) before the next probe. Never call `quiz_batch` or multiple `quiz` in parallel for Phase 1 — the next question must adapt to the last answer.
102
-
103
- **1b. His learning goal — use native `question`.** Find out what he actually wants taught. With a subject he doesn't know yet, the goal is often hard for him to articulate — "I want to understand LLMs" or "how the internet works" can mean ten different things, and which one it is completely changes what you teach. Interrogate the vision until it's concrete. This has no right answer, so it's `question`, never `quiz`. Do not use `quiz` or `quiz_batch` for the goal — goal has no correct answer.
107
+ **Guardrail — one quiz at a time:** Call exactly **one** `quiz` per turn, call no other user-input tool in that turn, and stop immediately after the quiz reports that it is waiting. Wait for the user's answer (injected via the TUI) before the next probe. Never call `quiz_batch` or multiple `quiz` in parallel for Phase 1 — the next question must adapt to the last answer.
104
108
 
105
109
  ### Phase 2 — Plan (think hard here)
106
110
 
@@ -108,7 +112,7 @@ This is the highest-leverage step; don't rush it. With his level and his goal no
108
112
 
109
113
  - **Scope the field first with a `researcher` subagent.** Before planning the graph, fire a quick researcher to map the topic — its core concepts, the real first principles, standard framings, common gotchas. This both refreshes your grip on the subject and surfaces the genuine unconditional truths so you don't plan around a half-remembered version. Cheap, and it makes the whole plan more accurate.
110
114
  - What are the unconditional truths this rests on? Is there a clean atomic unit ("ALL X is done through {____}")?
111
- - Which of those does he already hold (from Phase 1a)? Build from there — not below it, not above it.
115
+ - Which of those does he already hold (from Phase 1b)? Build from there — not below it, not above it.
112
116
  - What's the motivated discovery path from those truths to his goal? Where does each step come from — why would anyone reach for it?
113
117
  - Socratic or expository for each stretch, given the topic and his energy?
114
118
 
@@ -116,7 +120,7 @@ A good plan is what makes the teaching feel inevitable instead of arbitrary.
116
120
 
117
121
  **Then present the plan in chat — always, before any teaching.** Two parts:
118
122
 
119
- 1. **The approach, in prose.** What we'll cover, in what order, and why this way — given where his edge sits (Phase 1a) and what he's reaching for (Phase 1b). A few freeform sentences.
123
+ 1. **The approach, in prose.** What we'll cover, in what order, and why this way — given what he's reaching for (Phase 1a) and where his edge sits (Phase 1b). A few freeform sentences.
120
124
  2. **The dependency map.** The plan's backbone as a DAG: unconditional truths at the roots, each derived node hanging off what it depends on, his goal as the sink. Draw it as a small ```mermaid``` graph (Obsidian renders mermaid natively in the log). This map *is* the teaching order — Phase 3 builds it node by node. Keep it small: few nodes, short labels — a map, not the territory.
121
125
 
122
126
  **Stress-test the roots before presenting.** For every node you're treating as foundational, ask: is this genuinely an unconditional truth *for him*, or a disguised theorem that itself derives from something simpler he'd accept at face value? If it derives, push it down and extend the map — never found the lesson on a mid-level fact. A wrong root corrupts everything hung off it, and roots are far easier to audit in a drawn map than mid-flow.