eklavya 1.59.0 → 1.61.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.
Files changed (33) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/dashboard.html +131 -56
  3. package/dist/assets/tutor/SKILL.md +49 -44
  4. package/dist/assets/tutor/references/focus-and-level.md +2 -1
  5. package/dist/assets/tutor/references/grading.md +101 -104
  6. package/dist/assets/tutor/references/panel.md +5 -2
  7. package/dist/assets/tutor/references/writing-mcq.md +28 -20
  8. package/dist/eval/conversation-score.js +98 -0
  9. package/dist/eval/conversation-score.js.map +1 -0
  10. package/dist/eval/question-checks.js +31 -0
  11. package/dist/eval/question-checks.js.map +1 -1
  12. package/dist/hooks/ask-label.js +37 -14
  13. package/dist/hooks/ask-label.js.map +1 -1
  14. package/dist/migrations/027_invalid_questions.sql +19 -0
  15. package/dist/panel.js +17 -0
  16. package/dist/panel.js.map +1 -1
  17. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  18. package/dist/plugin/agents/tutor.md +26 -23
  19. package/dist/plugin/hooks/CLAUDE.md +1 -1
  20. package/dist/plugin/skills/CLAUDE.md +1 -1
  21. package/dist/plugin/skills/tutor/SKILL.md +49 -44
  22. package/dist/plugin/skills/tutor/references/focus-and-level.md +2 -1
  23. package/dist/plugin/skills/tutor/references/grading.md +101 -104
  24. package/dist/plugin/skills/tutor/references/panel.md +5 -2
  25. package/dist/plugin/skills/tutor/references/writing-mcq.md +28 -20
  26. package/dist/store.js +15 -1
  27. package/dist/store.js.map +1 -1
  28. package/dist/tools/panel_tools.js +1 -1
  29. package/dist/tools/panel_tools.js.map +1 -1
  30. package/dist/tools/record_attempt.js +26 -8
  31. package/dist/tools/record_attempt.js.map +1 -1
  32. package/dist/user-skill/eklavya/SKILL.md +1 -1
  33. package/package.json +1 -1
package/README.md CHANGED
@@ -61,7 +61,7 @@ page in the dashboard, where the server grades the new pick, and `option_notes`
61
61
  (the note under each option, in order), which that page shows under each option. A `correct` that is not one of
62
62
  the options, or notes of the wrong length, is stored as nothing and reported
63
63
  back (`correct_mismatch`, `option_notes_mismatch`); the answer is still kept.
64
- An `outcome` of `declined` or `dont_know` with a grade of 3 or more is
64
+ An `outcome` of `invalid` means the question itself was at fault (it assumed context the learner never saw, or two options both answered it): it is stored in `invalid_questions`, grades nothing, returns no `attempt_id`, and changes no mastery, level, review date or gate; only the repeat check reads it. An `outcome` of `declined` or `dont_know` with a grade of 3 or more is
65
65
  contradictory: it returns `error: "outcome_grade_conflict"` and records
66
66
  nothing, so that call can never update mastery, clear a gate or count toward a
67
67
  level.
@@ -72,7 +72,7 @@ level.
72
72
  right one, 2 for a near miss, 1 for a misconception), the right one in the
73
73
  plan's `answer_position` slot, and an explanation. It stores the question and
74
74
  returns at once; it writes no attempt. The panel records the answer through
75
- `panel_answer` with the same code `record_attempt` uses, exactly once. On a miss with `explain_on_wrong` on, its reply carries the same `explain` block `record_attempt` returns, and the panel starts the explainer agent from it. `present_question` returns `panel_disabled` while `quiz.panel` is off, and
75
+ `panel_answer` with the same code `record_attempt` uses, exactly once. On a miss with `explain_on_wrong` on, its reply carries the same `explain` block `record_attempt` returns, and the panel starts the explainer agent from it. `present_question` sends a question back once with `conspicuous_options` (nothing stored) when one option's label plus description outruns the next by a few words or a description explains itself, and presents the same stem as written on the second call. It returns `panel_disabled` while `quiz.panel` is off, and
76
76
  `panel_sync` returns `{disabled: true}` and expires the session's open question without recording an attempt, so turning the panel back on cannot revive it; `panel_answer` answers only a question
77
77
  that already exists. A round the learner asked for (a topic, `max`) is remembered per session: `present_question` takes each question off it and sets the last one's `more` false, and `get_session_quiz_plan` with `resume_round: true` plans what is left. This is
78
78
  a Claude Code mod feature: a standalone MCP client has the tools but not the
@@ -813,7 +813,6 @@
813
813
  .set__ctl input[type="number"], .set__ctl textarea { font:inherit; font-size:13.5px; background:var(--bg); color:var(--ink);
814
814
  border:1px solid var(--line-2); border-radius:0; padding:7px var(--space-3); width:100%; }
815
815
  .set__ctl textarea { font-family:var(--font-mono); font-size:12.5px; resize:vertical; min-height:64px; }
816
- .set__ctl input[type="checkbox"] { width:18px; height:18px; accent-color:var(--spot); margin:4px 0; cursor:pointer; }
817
816
  .set__ctl :disabled { opacity:.55; cursor:not-allowed; }
818
817
  .set__rule { color:var(--faint); }
819
818
  .set__err { margin:var(--space-2) 0 0; color:var(--warning); font-size:12.5px; overflow-wrap:anywhere; }
@@ -821,6 +820,58 @@
821
820
  .set__btn { font-family:var(--font-mono); font-size:11px; letter-spacing:.06em; text-transform:uppercase;
822
821
  background:none; border:1px solid var(--line-2); color:var(--dim); padding:2px 8px; cursor:pointer; }
823
822
  .set__btn:hover { color:var(--ink); border-color:var(--spot); }
823
+ /* ---------- Settings switchboard: scopes on the right, one tab of settings at a time ---------- */
824
+ .sw { display:grid; grid-template-columns:minmax(0,1fr) 208px; gap:var(--space-5); align-items:stretch; min-height:calc(100vh - 168px); }
825
+ .sw__scopes { align-self:stretch; display:flex; flex-direction:column; gap:2px; align-content:flex-start;
826
+ background:var(--panel); border:1px solid var(--line-2); padding:var(--space-2); }
827
+ .sw__cap { margin:var(--space-3) var(--space-2) var(--space-1); font-family:var(--font-mono); font-size:10.5px;
828
+ letter-spacing:var(--tracking-caps); text-transform:uppercase; color:var(--faint); }
829
+ .sw__cap:first-child { margin-top:var(--space-1); }
830
+ .sw__projects { display:flex; flex-direction:column; gap:2px; overflow-y:auto; }
831
+ .sw__scope { display:flex; align-items:center; gap:10px; padding:8px 10px; color:var(--dim); text-decoration:none;
832
+ font-size:13.5px; min-width:0; transition:background var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease); }
833
+ .sw__scope svg { width:16px; height:16px; flex:none; }
834
+ .sw__scope span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
835
+ .sw__scope:hover { background:var(--mass); color:var(--ink); }
836
+ .sw__scope[aria-current="page"] { background:var(--mass); color:var(--ink); box-shadow:inset 2px 0 0 var(--spot); }
837
+ .sw__scope[aria-current="page"] svg { color:var(--spot); }
838
+ .sw__none { margin:var(--space-1) var(--space-2) var(--space-2); color:var(--faint); font-size:12.5px; }
839
+ .sw__pane { min-width:0; display:flex; flex-direction:column; gap:var(--space-5); }
840
+ .sw__tabs { display:flex; gap:0; border-bottom:1px solid var(--line-2); overflow-x:auto; scrollbar-width:none; }
841
+ .sw__tabs::-webkit-scrollbar { display:none; }
842
+ .sw__tab { display:flex; align-items:center; gap:6px; padding:10px 9px; margin-bottom:-1px; white-space:nowrap;
843
+ font-family:var(--font-mono); font-size:10.5px; letter-spacing:.1em; text-transform:uppercase;
844
+ color:var(--faint); text-decoration:none; border-bottom:2px solid transparent;
845
+ transition:color var(--duration-fast) var(--ease); }
846
+ .sw__tab:hover { color:var(--ink); }
847
+ .sw__tab[aria-current="page"] { color:var(--ink); border-bottom-color:var(--spot); }
848
+ .sw__tab i { font-style:normal; color:var(--spot); }
849
+ .card.sw__list { padding:0; }
850
+ .sw__list .set, .sw__list .set:first-child, .sw__list .set:last-child { padding:var(--space-4) var(--space-5); border-bottom:1px solid var(--line); }
851
+ .sw__list .set:last-child { border-bottom:none; }
852
+ .set--bool { align-items:center; flex-wrap:nowrap; }
853
+ .set--bool .set__ctl { flex:0 0 auto; }
854
+ /* A switch: a square track and thumb, the accent when on. It is still a checkbox underneath. */
855
+ .set__ctl input[type="checkbox"] { appearance:none; -webkit-appearance:none; width:44px; height:24px; margin:0; position:relative;
856
+ background:var(--mass); border:1px solid var(--line-2); border-radius:0; cursor:pointer;
857
+ transition:background var(--duration-fast) var(--ease), border-color var(--duration-fast) var(--ease); }
858
+ .set__ctl input[type="checkbox"]::after { content:""; position:absolute; top:2px; left:2px; width:18px; height:18px;
859
+ background:var(--dim); transition:transform var(--duration-fast) var(--ease), background var(--duration-fast) var(--ease); }
860
+ .set__ctl input[type="checkbox"]:checked { background:var(--spot); border-color:var(--spot); }
861
+ .set__ctl input[type="checkbox"]:checked::after { transform:translateX(20px); background:var(--spot-ink); }
862
+ @media (max-width:900px) {
863
+ .sw { grid-template-columns:minmax(0,1fr); gap:var(--space-4); min-height:0; }
864
+ .sw__scopes { order:-1; align-self:auto; flex-direction:row; align-items:center; overflow-x:auto; scrollbar-width:none; }
865
+ .sw__scopes::-webkit-scrollbar { display:none; }
866
+ .sw__cap { display:none; }
867
+ .sw__projects { flex-direction:row; max-height:none; overflow:visible; }
868
+ .sw__scope { white-space:nowrap; }
869
+ .sw__none { margin:0; white-space:nowrap; }
870
+ }
871
+ @media (max-width:560px) {
872
+ .sw__list .set, .sw__list .set:first-child, .sw__list .set:last-child { padding:var(--space-4); }
873
+ .set--bool { flex-wrap:wrap; }
874
+ }
824
875
  /* A config path is one unbroken word; on a phone it has to break anywhere. */
825
876
  .counts .mono, .lines .mono { overflow-wrap:anywhere; }
826
877
  /* ---------- feature tips: Driver.js hints restyled, vendor CSS untouched ---------- */
@@ -3118,8 +3169,8 @@ const TIPS = [
3118
3169
  when: () => (S.artifacts ?? []).some((a) => a.correction === 'open') },
3119
3170
  { id: 'artifact-tabs', where: ['artifacts/view'], el: '#view [role="tablist"]', title: 'Open pages',
3120
3171
  text: 'Pages you open stay here as tabs, up to eight. All artifacts takes you back.' },
3121
- { id: 'settings-scope', where: ['settings/dashboard'], el: '#nav', side: 'right', title: 'User or project',
3122
- text: 'User settings apply everywhere. Project settings override them for one project.' },
3172
+ { id: 'settings-scope', where: ['settings/dashboard'], el: '.sw__scopes', side: 'left', title: 'User or project',
3173
+ text: 'User settings apply everywhere. Pick a project to override them for that project only.' },
3123
3174
  { id: 'ground', where: ['settings/dashboard'], el: '.ground[aria-label="Colour ground"]', side: 'right', title: 'Ink or paper',
3124
3175
  text: 'Switch the dashboard between a dark and a light ground.' },
3125
3176
  ];
@@ -3492,7 +3543,7 @@ function settingRow(f, d, scope) {
3492
3543
  ? (Object.hasOwn(d.user.set, f.key) ? 'inherited from user settings' : 'default')
3493
3544
  : 'default'}</span>`;
3494
3545
  }
3495
- return `<div class="set">
3546
+ return `<div class="set${f.type === 'bool' ? ' set--bool' : ''}">
3496
3547
  <div class="set__main"><label for="${id}">${esc(f.label)}</label>
3497
3548
  <p id="${id}-h">${esc(f.help)}${ruleHint(f) ? ` <span class="set__rule">${esc(ruleHint(f))}</span>` : ''}</p>
3498
3549
  <p class="set__src"><code>${esc(f.key)}</code>${src}<span data-msg="${esc(f.key)}" role="status"></span></p></div>
@@ -3500,37 +3551,86 @@ function settingRow(f, d, scope) {
3500
3551
  </div>`;
3501
3552
  }
3502
3553
 
3554
+ /** A category's tab id: `This machine` is `this-machine`. */
3555
+ const swTab = (group) => group.toLowerCase().replace(/\W+/g, '-');
3556
+ const SW_ICON = {
3557
+ user: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
3558
+ project: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
3559
+ };
3560
+
3561
+ /**
3562
+ * The switchboard. On the left, the scope: you (user settings, the default) or
3563
+ * one project. On the right, that scope's settings one category at a time, each
3564
+ * category a tab in the URL (`#/settings/dashboard/memory`). Picking another scope
3565
+ * keeps the tab. `Terminal` lists what stays a typed command; `Changed` lists
3566
+ * what this scope's file actually sets.
3567
+ */
3503
3568
  function drawSettings(d, scope) {
3504
3569
  SETS = d;
3505
- const W = scope === 'project' ? 'Project settings' : 'User settings';
3570
+ const tabParam = (id) => (id === swTab(d.fields[0].group) ? null : id);
3571
+ const page = scope === 'project' ? 'project' : 'dashboard';
3572
+ const file = scope === 'project' ? d.project : d.user;
3573
+ const groups = [...new Set(d.fields.map((f) => f.group))];
3574
+ const tabs = groups.map((g) => {
3575
+ const fields = d.fields.filter((f) => f.group === g);
3576
+ return { id: swTab(g), label: g, fields, n: file ? fields.filter((f) => Object.hasOwn(file.set, f.key)).length : 0 };
3577
+ });
3578
+ tabs.push({ id: 'terminal', label: 'Terminal', n: 0 }, { id: 'changed', label: 'Changed', n: file ? Object.keys(file.set).length : 0 });
3579
+ const here = tabs.find((t) => t.id === CUR.param) ?? tabs[0];
3580
+
3581
+ const scopeLink = (label, icon, to, current) =>
3582
+ `<a class="sw__scope" href="${esc(to)}"${current ? ' aria-current="page"' : ''}>${svg(icon)}<span>${esc(label)}</span></a>`;
3583
+ const rail = `<nav class="sw__scopes" aria-label="Settings scope">
3584
+ <p class="sw__cap">Everywhere</p>
3585
+ ${scopeLink('User', SW_ICON.user, SH('dashboard', tabParam(here.id)), scope === 'user')}
3586
+ <p class="sw__cap">Projects</p>
3587
+ <div class="sw__projects">${d.projects.length
3588
+ ? d.projects.map((p) => scopeLink(p.name, SW_ICON.project, SH('project', tabParam(here.id), { project: p.inventory }),
3589
+ scope === 'project' && d.project?.inventory === p.inventory)).join('')
3590
+ : '<p class="sw__none">No project with a checkout on this machine yet.</p>'}</div>
3591
+ </nav>`;
3592
+
3506
3593
  const about = `User settings apply everywhere; a project setting wins in that project only, and <b>Inherit</b>
3507
3594
  removes it so the user setting applies again. Every change here is the same write as
3508
3595
  <code>eklavya config set &lt;key&gt; &lt;value&gt;</code> (with <code>--project</code> for a project), and
3509
3596
  <b>Inherit</b> / <b>Reset</b> is <code>eklavya config unset</code>. Nothing is written inside a repository.`;
3510
- if (scope === 'project' && !d.project) {
3511
- const known = d.projects.filter((p) => projectById(p.inventory));
3512
- return [
3513
- pageHead(W, [plural(known.length, 'project')], about),
3597
+
3598
+ if (!file) {
3599
+ return `<div class="sw"><div class="sw__pane">${[
3600
+ pageHead('Project settings', [], about),
3514
3601
  nextStep(PROJECT
3515
- ? `<strong>${esc(scopeName())}</strong> has no git checkout on this machine, so there is no project file to change. Pick one below.`
3516
- : 'Pick a project to change its settings, or use the project selector.'),
3517
- card(null, null, lines(known.map((p) => ({ t: esc(p.name), s: `<span class="mono">${esc(p.id)}</span>`,
3518
- go: SH('project', null, { project: p.inventory }) })), 'No project with a checkout on this machine yet.')),
3519
- ].join('');
3602
+ ? `<strong>${esc(scopeName())}</strong> has no git checkout on this machine, so there is no project file to change. Pick another project on the right.`
3603
+ : 'Pick a project on the right to change its settings.'),
3604
+ ].join('')}</div>${rail}</div>`;
3520
3605
  }
3521
- const file = scope === 'project' ? d.project : d.user;
3522
- const groups = [...new Set(d.fields.map((f) => f.group))];
3523
- const setHere = Object.keys(file.set).length;
3524
- return [
3525
- pageHead(scope === 'project' ? `${esc(d.project.name)} settings` : W,
3526
- [`<span class="mono">${esc(file.path)}</span>`, `${setHere} set here`], about),
3527
- d.problem ? nextStep(`<strong>A config file is not being read.</strong> ${esc(d.problem)}`) : '',
3528
- ...groups.map((g) => card(g, null, d.fields.filter((f) => f.group === g).map((f) => settingRow(f, d, scope)).join(''))),
3529
- fold(`settings:${scope}:cli`, 'Changed from the terminal', plural(d.cli_only.length, 'setting'),
3606
+
3607
+ const tabBar = `<nav class="sw__tabs" aria-label="Setting categories">${tabs.map((t) =>
3608
+ `<a class="sw__tab" href="${esc(SH(page, tabParam(t.id)))}"${t.id === here.id ? ' aria-current="page"' : ''}>${esc(t.label)}${t.n ? `<i title="${t.n} set in this scope">${t.n}</i>` : ''}</a>`).join('')}</nav>`;
3609
+
3610
+ let body;
3611
+ if (here.id === 'terminal') {
3612
+ body = card('Changed from the terminal', 'These send work off this machine or run a command, so they stay a typed command: eklavya config set <key> <value>.',
3530
3613
  lines(d.cli_only.map((c) => ({ t: `<code>${esc(c.key)}</code>`, s: esc(c.why),
3531
- v: `<span class="mono">${esc(showValue(file.effective[c.key]))}</span>` }))),
3532
- { hint: 'These send work off this machine or run a command, so they stay a typed command: eklavya config set <key> <value>.' }),
3533
- ].join('');
3614
+ v: `<span class="mono">${esc(showValue(file.effective[c.key]))}</span>` }))));
3615
+ } else if (here.id === 'changed') {
3616
+ const tabOf = (k) => { const f = d.fields.find((x) => x.key === k); return f ? swTab(f.group) : 'terminal'; };
3617
+ body = card(`Set ${scope === 'project' ? 'for this project' : 'by you'}`,
3618
+ scope === 'project' ? 'Everything else inherits from user settings.' : 'Everything else is the default.',
3619
+ lines(Object.keys(file.set).map((k) => ({ t: `<code>${esc(k)}</code>`,
3620
+ s: scope === 'project' ? esc(`user: ${showValue(d.user.effective[k])}`) : '',
3621
+ v: `<span class="mono">${esc(showValue(file.set[k]))}</span>`, go: SH(page, tabParam(tabOf(k))) })),
3622
+ scope === 'project' ? 'This project overrides nothing; user settings apply.' : 'Nothing set: every setting is at its default.'));
3623
+ } else {
3624
+ body = `<section class="card sw__list" aria-label="${esc(here.label)} settings">${here.fields.map((f) => settingRow(f, d, scope)).join('')}</section>`;
3625
+ }
3626
+
3627
+ return `<div class="sw"><div class="sw__pane">${[
3628
+ pageHead(scope === 'project' ? `${esc(d.project.name)} settings` : 'User settings',
3629
+ [`<span class="mono">${esc(file.path)}</span>`, `${Object.keys(file.set).length} set here`], about),
3630
+ d.problem ? nextStep(`<strong>A config file is not being read.</strong> ${esc(d.problem)}`) : '',
3631
+ tabBar,
3632
+ body,
3633
+ ].join('')}</div>${rail}</div>`;
3534
3634
  }
3535
3635
 
3536
3636
  /* ============================================================
@@ -3736,30 +3836,7 @@ function fbDelete(btn) {
3736
3836
 
3737
3837
  function viewSettings(scope) {
3738
3838
  fill('settings', settingsUrl(), (d) => drawSettings(d, scope));
3739
- return `<div class="stack" id="settings">${loader('Reading your settings…')}</div>`;
3740
- }
3741
-
3742
- function viewSettingsHome() {
3743
- fill('settings', settingsUrl(), (d) => {
3744
- SETS = d;
3745
- const proj = d.project;
3746
- const overrides = proj ? Object.keys(proj.set).filter((k) => !d.global_only.includes(k)) : [];
3747
- return [
3748
- pageHead('Settings', [plural(Object.keys(d.user.set).length, 'user setting'),
3749
- proj ? `${plural(overrides.length, 'project setting')} in ${esc(proj.name)}` : 'no project selected'],
3750
- `Two files, one precedence: the project's file wins over yours, key by key, and anything neither sets is the
3751
- default. The terminal writes the same files — <code>eklavya config get</code> shows what is in force.`),
3752
- card(null, null, lines([
3753
- { t: 'User settings', s: `<span class="mono">${esc(d.user.path)}</span>`, v: esc(`${Object.keys(d.user.set).length} set`), go: SH('user') },
3754
- { t: 'Project settings', s: proj ? `<span class="mono">${esc(proj.path)}</span>` : 'choose a project',
3755
- v: proj ? esc(`${overrides.length} set`) : '', go: SH('project') },
3756
- ])),
3757
- proj && overrides.length ? card(`What ${proj.name} changes`, null, lines(overrides.map((k) => ({
3758
- t: `<code>${esc(k)}</code>`, s: esc(`user: ${showValue(d.user.effective[k])}`),
3759
- v: `<span class="mono">${esc(showValue(proj.set[k]))}</span>`, go: SH('project') })))) : '',
3760
- ].join('');
3761
- });
3762
- return `<div class="stack" id="settings">${loader('Reading your settings…')}</div>`;
3839
+ return `<div id="settings">${loader('Reading your settings…')}</div>`;
3763
3840
  }
3764
3841
 
3765
3842
  /** Reads a control back into the value its field stores. */
@@ -3914,13 +3991,11 @@ const WORKFLOWS = {
3914
3991
  },
3915
3992
  settings: {
3916
3993
  name: 'Settings',
3917
- groups: [
3918
- { id: 'scopes', label: 'Scopes', items: ['user', 'project'] },
3919
- ],
3994
+ groups: [],
3920
3995
  pages: {
3921
- dashboard: { label: 'Overview', view: () => viewSettingsHome() },
3922
- user: { label: 'User', view: () => viewSettings('user') },
3923
- project: { label: 'Project', view: () => viewSettings('project') },
3996
+ dashboard: { label: 'Preferences', view: () => viewSettings('user') },
3997
+ user: { parent: 'dashboard', view: () => viewSettings('user') },
3998
+ project: { parent: 'dashboard', view: () => viewSettings('project') },
3924
3999
  },
3925
4000
  },
3926
4001
  };
@@ -9,8 +9,16 @@ You are teaching a real person, not generating a lesson. They are watching you
9
9
  build something; your job is to make sure they could have built it themselves
10
10
  next time.
11
11
 
12
- Everything you learn about them persists in the Eklavya MCP server. Use it — the
13
- whole point is never asking the same question twice.
12
+ Everything you learn about them persists in the Eklavya MCP server: use it to
13
+ never ask the same question twice.
14
+
15
+ ## The flow, and who wins
16
+
17
+ Profile → plan → build the question → present → classify the reply → record →
18
+ feedback → back to the task. Where rules collide, the order is: the learner's
19
+ words (stop, skip, "teach me"); the plan (budget, `tier_to_ask`, `framing`, the
20
+ answer's slot); the pedagogy in these files; the renderer's mechanics. An
21
+ exception sits beside the rule it overrides.
14
22
 
15
23
  ## Red flags
16
24
 
@@ -19,16 +27,17 @@ yourself thinking one, the right-hand column is what is actually happening.
19
27
 
20
28
  | The thought | What it is |
21
29
  |---|---|
22
- | "I'll log the concepts once the task is done." | A checkpoint can only fire while the work is happening. End-of-task logging restores the pile it replaced. |
23
- | "One more question while they're engaged." | The plan said one. Two is the batch, arriving early. |
30
+ | "I'll log the concepts once the task is done." | A checkpoint can only fire mid-work. End-of-task logging restores the pile it replaced. |
31
+ | "One more question while they're engaged." | The plan said one. Two is the batch, early. |
24
32
  | "The plan returned one item but there's more worth asking." | Calling the plan again refills a budget the server deliberately spent. |
25
33
  | "They got it, near enough." | An inflated grade is a gate that passes without learning — the one failure that makes this tool pointless. |
26
- | "Two blanks in a row; I should offer to stop." | You are pitching too high. Drop a tier and keep going. Wait to be told to stop. |
27
- | "I'll reword the question they got wrong." | A question in `asked_before` is spent, not recyclable. |
28
- | "A definition is the transferable version of this." | `concept` focus wants the general rule. "What is X" is tier-1 recall wearing a hat. |
29
- | "Too easy to be worth asking — I'll add a 'why'." | `easy` is tiers 1–2 and it was earned. A smuggled "why" is a question they cannot answer honestly. |
30
- | "They know what 'Task 6' means." | They saw none of it. Define the name or drop it. |
31
- | "I'll put the dials above the stem for context." | The dials are in the status bar. A line you assemble is one the server cannot keep consistent. |
34
+ | "Two blanks in a row; I should offer to stop." | The planner already lowers the tier. Keep going at `tier_to_ask`; wait to be told to stop. |
35
+ | "They said it lacks context; I'll record a blank." | That grades them 0 for your mistake. Record `invalid`. |
36
+ | "I'll reword the question they got wrong." | A question in `asked_before` is spent. |
37
+ | "A definition is the transferable version of this." | `concept` focus wants the general rule; "What is X" is tier-1 recall. |
38
+ | "Too easy to be worth asking — I'll add a 'why'." | `easy` is tiers 1–2 and was earned. A smuggled "why" cannot be answered honestly. |
39
+ | "They know what 'Task 6' means." | They saw none of it. Define it or drop it. |
40
+ | "I'll put the dials above the stem for context." | They are in the status bar; a line you assemble cannot stay consistent. |
32
41
  | "Grade 5 — they picked the right option." | Multiple choice caps at 4. One in four is a coin. |
33
42
 
34
43
  ## While you work
@@ -49,9 +58,9 @@ Unknown slugs are fine — they are fuzzy-matched or created. Read the response:
49
58
  `domain`, an honest `tier` and at least one `prerequisite_of` edge. Without
50
59
  edges, `prereqs_unmet` is always empty and the fairness check silently passes.
51
60
 
52
- Logging is silent — never narrate it, never pause the work to announce it. It is
53
- also the trigger: on `as-you-go` cadence, the default, the log call may come
54
- straight back with a checkpoint.
61
+ Logging is silent — never narrate it or pause the work to announce it. On
62
+ `as-you-go` cadence, the default, the log call may come straight back with a
63
+ checkpoint.
55
64
 
56
65
  ## Which one is asking
57
66
 
@@ -74,18 +83,18 @@ too. Call `get_session_quiz_plan`, run the quiz and follow its `on_skip` and
74
83
  once in a long session (paced by the clock), so treat each as its own single
75
84
  question, not a sign you missed the last one.
76
85
 
77
- **Your task answer goes last.** Write the answer when the task is done. If the
78
- Stop sweep then asks for questions, ask them one at a time with a verdict each,
79
- and end with "Back to your task:" and the answer again in 2-4 lines. Never end
80
- a turn on a question or a verdict.
86
+ **Your task answer goes last.** If the Stop sweep asks for questions after the
87
+ task is done, ask them one at a time with a verdict each, and end with "Back to
88
+ your task:" and the answer again in 2-4 lines. Never end a turn on a question
89
+ or a verdict.
81
90
 
82
91
  **While an agent builds.** With `delegate_work` on (the default), non-trivial
83
92
  code changes go to background agents and the wait is when you teach. A hook
84
93
  repeats this once, when you edit a second file yourself. Start
85
94
  them, log the plan's concepts, then loop: `get_session_quiz_plan` with
86
95
  `while_waiting: true`, ask, grade, verdict. Stop when an agent reports or
87
- `questions_needed` is 0, then write the task answer last. Agents cannot ask,
88
- and hooks do not quiz inside them. Keep small fixes and lookups inline.
96
+ `questions_needed` is 0, then write the task answer last. Agents cannot ask, and hooks
97
+ do not quiz inside them. Keep small fixes and lookups inline.
89
98
 
90
99
  **How many questions is not your call — it is the plan's.** Under `as-you-go`
91
100
  the plan returns one item, the sweep included. Under `end`, and when enforced,
@@ -130,9 +139,9 @@ The promise the whole tool rests on.
130
139
 
131
140
  - **A question in `asked_before` is spent** — not "reword it", spent. Ask a
132
141
  different thing about the same concept.
133
- - **Never ask about a slug in `known`** unless it is also in `due_for_review`.
134
- Spaced repetition is the only reason a mastered concept returns, and it returns
135
- harder.
142
+ - **Never ask about a slug in `known`** unless it is also in `due_for_review`
143
+ or its plan item is `reason: "gate_work"` (an enforced gate lists it even when
144
+ mastered; skipping it can make the gate impossible to pass).
136
145
  - `record_attempt` returns `repeat_question: true` if you broke this. Treat it as
137
146
  a mistake you just made.
138
147
 
@@ -144,7 +153,7 @@ Never post a numbered list of five — that is a test, not teaching.
144
153
  **Ask it as multiple choice, using `AskUserQuestion`** — or `present_question` when
145
154
  the plan's `presentation` is `"panel"`: it returns at once and the panel records
146
155
  and judges the answer, so never wait, `record_attempt` or give a verdict.
147
- Nobody mid-task types a paragraph for a quiz they did not ask for. **Read
156
+ **Read
148
157
  `references/writing-mcq.md` before writing one**: the six parts in build order,
149
158
  where distractors come from, how to record it; for `"panel"` also
150
159
  `references/panel.md`.
@@ -167,27 +176,25 @@ a suggestion: above it is a question the learner has not reached.
167
176
 
168
177
  Definitions are tier 1 **only**. "What is X" at tier 3 is a bad question.
169
178
 
170
- **The stem is the whole question, and the plan's `ask_attribution` says how to
171
- sign it.** The dials live in the developer's status bar — `[EKLAVYA
172
- concept · as-you-go · easy]` — so they never go in the stem. Who is asking is
173
- the one exception, and whether it belongs in the stem depends on the host:
174
- follow `ask_attribution` rather than deciding.
179
+ **The stem is the whole question; the plan's `ask_attribution` says how to
180
+ sign it.** The dials live in the status bar, so they never go in the stem.
181
+ Whether who is asking belongs in the stem depends on the host: follow
182
+ `ask_attribution`.
175
183
 
176
184
  ## Grading, and blanks
177
185
 
178
- `record_attempt` for **every** answer you collect, blanks and declines included, with
179
- `question` verbatim and `outcome` as `answered`, `dont_know` or `declined`.
180
- Grade honestly on 0–5; multiple choice caps at 4 and the server enforces it.
181
-
182
- "I don't know" is not a skip. It is the clearest request for teaching you will
183
- ever get, and answering it with a three-sentence correction is the failure this
184
- tool exists to prevent. **Read `references/grading.md` before you grade** — both
185
- scales, how long feedback may be, the sequence a blank earns, and what
186
- `already_taught` changes.
186
+ `record_attempt` for **every** answer, with `question` verbatim and `outcome` as
187
+ `answered`, `dont_know`, `declined` or `invalid` (the question was at fault;
188
+ grades nothing). Multiple choice caps at 4. "I don't know" is not a skip: it is a
189
+ request for teaching. **Read `references/grading.md` before you grade**: what
190
+ each response earns by context, both scales, the teaching a blank earns, and
191
+ `already_taught`.
187
192
 
188
193
  **A missed answer can come back with `explain`.** That is `explain_on_wrong` at
189
194
  work: follow its `instruction` exactly. Hand the explainer its `attempt_id` and
190
- `option_notes` too: the page shows the notes and can be corrected. The page is written in the background
195
+ `option_notes` too: the page shows the notes and can be corrected. A parallel
196
+ tutor's reply ends with a handoff block (`agents/tutor.md`): start the
197
+ explainer from it, every field included. The page is written in the background
191
198
  and opens by itself, so give the verdict, say the page is on its way, and go
192
199
  back to the task — no longer explanation, no waiting. When the developer asks
193
200
  for something to be explained as a page ("explain this to me", "make me a page
@@ -200,12 +207,10 @@ ask, **difficulty** is how hard questions may get. They are independent and
200
207
  every combination is coherent.
201
208
 
202
209
  You choose none of them — the plan and the hooks do. **Read
203
- `references/focus-and-level.md` before you quiz**: the three focuses and what
204
- "grounded" means in each, the earned level bands, the cadence contract, and the
205
- enforced gate retry that is the only route out of a blocked commit.
210
+ `references/focus-and-level.md` before you quiz**: the three focuses, the earned
211
+ level bands, the cadence contract, and the enforced gate retry.
206
212
 
207
213
  ## The bar
208
214
 
209
- The developer should finish a quiz thinking *"I understand what we just built"*
210
- — not *"I passed."* If a question only proves they read the diff, it was the
211
- wrong question.
215
+ The developer should finish a quiz thinking *"I understand what we just built"*,
216
+ not *"I passed."* A question that only proves they read the diff was the wrong one.
@@ -204,7 +204,8 @@ into the batch it replaced.
204
204
  is the only route out of the gate, so do not skip past it, and do not treat it
205
205
  as the tool repeating itself.
206
206
 
207
- A concept they explicitly **declined** is not offered again. That is
207
+ A concept they explicitly **declined** is not offered again by this session's
208
+ retry (a later session's review may bring it back once it is due). That is
208
209
  deliberate: the gate holding against a decline is enforcement working. If they
209
210
  are stuck behind it, the honest thing to say is that answering the retry
210
211
  questions is the way through, not that the tool is broken.