create-pmos 0.47.0 → 0.49.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 (78) hide show
  1. package/package.json +1 -1
  2. package/payload/AGENTS-HUMAN.md +1 -1
  3. package/payload/AGENTS.md +1 -1
  4. package/payload/KIT-VERSION +2 -2
  5. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
  6. package/payload/mods/pmos-cockpit/README.md +14 -3
  7. package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +34 -6
  8. package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +22 -3
  9. package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +16 -3
  10. package/payload/mods/pmos-cockpit/hooks/lib/route.mjs +95 -0
  11. package/payload/mods/pmos-cockpit/hooks/register.tsx +104 -19
  12. package/payload/mods/pmos-cockpit/types/index.d.ts +1 -1
  13. package/payload/planning/evals/eval-calibration.md +1 -1
  14. package/payload/planning/okrs/health-budgets.md +1 -1
  15. package/payload/scripts/pmos-log.sh +18 -8
  16. package/payload/scripts/pmos-model.js +233 -2
  17. package/payload/scripts/status-facts.js +214 -0
  18. package/payload/scripts/workflow-state.js +59 -2
  19. package/payload/state/discoveries.md +1 -1
  20. package/payload/state/log.md +1 -1
  21. package/workspace/README.md +2 -2
  22. package/workspace/bin/live-notes.js +133 -0
  23. package/workspace/design-parity.js +328 -21
  24. package/workspace/doc-category-bench.js +165 -0
  25. package/workspace/fixtures/doc-category-labelled.json +167 -0
  26. package/workspace/guide/index.md +2 -1
  27. package/workspace/guide/initiatives-and-next-steps.md +17 -2
  28. package/workspace/guide/knowledge-graph.md +94 -1
  29. package/workspace/guide/layout.md +31 -2
  30. package/workspace/guide/live-notes.md +78 -0
  31. package/workspace/guide/proactive-engine.md +6 -2
  32. package/workspace/guide/validate-with-typesafe.md +31 -2
  33. package/workspace/lib/claims.js +51 -2
  34. package/workspace/lib/doc-category-corrections.js +113 -0
  35. package/workspace/lib/doc-category-writeback.js +110 -0
  36. package/workspace/lib/doc-category.js +225 -0
  37. package/workspace/lib/engine/pmos-model.js +233 -2
  38. package/workspace/lib/engine/status-facts.js +214 -0
  39. package/workspace/lib/engine/workflow-state.js +59 -2
  40. package/workspace/lib/jev-categorize.js +433 -0
  41. package/workspace/lib/jev-claims.js +59 -15
  42. package/workspace/lib/jev-live.js +37 -0
  43. package/workspace/lib/jev-route.js +139 -0
  44. package/workspace/lib/kg-cli.js +3 -1
  45. package/workspace/lib/kg.js +6 -1
  46. package/workspace/lib/learn.js +5 -3
  47. package/workspace/lib/live-notes.js +1035 -0
  48. package/workspace/lib/typesafe-settings.js +10 -2
  49. package/workspace/package-lock.json +1 -1
  50. package/workspace/package.json +5 -4
  51. package/workspace/public/pm/app.js +148 -79
  52. package/workspace/public/pm/data.js +78 -0
  53. package/workspace/public/pm/fixture.js +40 -2
  54. package/workspace/public/pm/modes-live.js +279 -18
  55. package/workspace/public/pm/panel-cockpit.js +638 -0
  56. package/workspace/public/pm/panel-detail.js +212 -8
  57. package/workspace/public/pm/panel-initiatives.js +9 -9
  58. package/workspace/public/pm/panel-newsess.js +145 -86
  59. package/workspace/public/pm/panel-repos.js +34 -17
  60. package/workspace/public/pm/panel-tabs.js +249 -29
  61. package/workspace/public/pm/state.js +48 -4
  62. package/workspace/public/pm/tab-doc.js +244 -16
  63. package/workspace/public/pm/tab-term.js +11 -1
  64. package/workspace/public/pm/tokens.css +4 -3
  65. package/workspace/public/pm/view-addrepo.js +207 -0
  66. package/workspace/public/pm/view-files.js +197 -0
  67. package/workspace/public/pm/view-guide.js +141 -0
  68. package/workspace/public/pm/view-knowledge.js +225 -0
  69. package/workspace/public/pm/view-livenotes.js +397 -0
  70. package/workspace/public/pm/view-memory.js +122 -0
  71. package/workspace/public/pm/view-portfolio.js +153 -0
  72. package/workspace/public/pm/view-reports.js +190 -0
  73. package/workspace/public/pm/view-scheduled.js +161 -0
  74. package/workspace/public/pm/view-skills.js +216 -0
  75. package/workspace/public/pm/view-today.js +168 -0
  76. package/workspace/public/pm/view-validate.js +171 -0
  77. package/workspace/server.js +349 -7
  78. package/workspace/skills/live-notes.skill +58 -0
@@ -23,23 +23,36 @@ const ROW = 'display:block;width:100%;padding:7px 14px;min-height:40px;text-alig
23
23
  const BTN = 'font-family:var(--mono);font-size:10.5px;padding:3px 9px;border:1px solid var(--border);color:var(--muted-foreground);min-height:26px';
24
24
  const NOTE = 'padding:8px 14px;font-size:11.5px;color:var(--muted-foreground);line-height:1.5';
25
25
 
26
- /* one cached read per endpoint; `reload(key)` drops it so the next render refetches */
26
+ /* one cached read per endpoint; `reload(key)` drops it so the next render refetches.
27
+ *
28
+ * AN ANSWER OLDER THAN A RELOAD IS DROPPED. `reload` follows a write (a saved setting, a dismissed
29
+ * item). A read that was already in flight when the write went out carries the state from BEFORE it,
30
+ * and it used to land after the fresh read and overwrite it — then count as fresh for 15 s. Measured
31
+ * on Today: quiet hours saved, the note shown, and 1.5 s later the note gone while the server held
32
+ * the setting (pm-r22-test.js, about 1 run in 8). `gen` counts reloads per key; a response whose
33
+ * generation is out of date is not written. */
27
34
  const cache = new Map();
35
+ const gen = new Map();
28
36
  function read(key, url) {
29
37
  const hit = cache.get(key);
30
38
  if (hit && (hit.state !== 'ok' || Date.now() - hit.at < 15000)) return hit;
31
39
  if (!hit || hit.state === 'ok') {
32
40
  const next = { state: hit ? hit.state : 'loading', data: hit ? hit.data : null, at: hit ? hit.at : 0 };
33
41
  cache.set(key, next);
42
+ const g = gen.get(key) || 0;
43
+ const current = () => (gen.get(key) || 0) === g;
34
44
  fetch(url).then(readJson).then(
35
- (data) => { cache.set(key, { state: data && data.ok === false ? 'error' : 'ok', data, error: data && data.error, at: Date.now() }); setState({}); },
36
- (e) => { cache.set(key, { state: 'error', error: (e && e.message) || String(e), at: Date.now() }); setState({}); },
45
+ (data) => { if (!current()) return; cache.set(key, { state: data && data.ok === false ? 'error' : 'ok', data, error: data && data.error, at: Date.now() }); setState({}); },
46
+ (e) => { if (!current()) return; cache.set(key, { state: 'error', error: (e && e.message) || String(e), at: Date.now() }); setState({}); },
37
47
  );
38
48
  return next;
39
49
  }
40
50
  return hit;
41
51
  }
42
- const reload = (key) => cache.delete(key);
52
+ const reload = (key) => { gen.set(key, (gen.get(key) || 0) + 1); cache.delete(key); };
53
+ /* refetch on the next render but keep showing the last answer meanwhile, so a list does not blank while it updates.
54
+ * It follows a write too, so it bumps the generation like `reload`: a read already in flight is older than the write. */
55
+ const refresh = (key) => { gen.set(key, (gen.get(key) || 0) + 1); const h = cache.get(key); if (h && h.state === 'ok') h.at = 0; else cache.delete(key); };
43
56
  /* A route this server does not have answers 404 with the plain text `not found`. Parsing that as JSON
44
57
  * showed the PM "Unexpected token 'o', \"not found\" is not valid JSON" — true, and no help. The usual
45
58
  * cause is a server still running code older than the page, so say that and the one command. */
@@ -275,7 +288,8 @@ async function memDo(body) {
275
288
  const out = await post('/api/memory', { ws, ...body });
276
289
  memNote = out && out.ok ? (out.staged ? 'held for approval' : null) : ((out && out.error) || 'not saved');
277
290
  if (out && !out.ok && out.entries && out.entries.length) memNote += ' — current entries: ' + out.entries.map((e) => '“' + e.text.slice(0, 40) + '”').join(', ');
278
- reload('memory'); setState({});
291
+ // the list caches its read per workspace, so the reload names that key; a bare 'memory' left the old list on screen for 15s
292
+ reload('memory:' + ws); reload('memory'); setState({});
279
293
  }
280
294
  const ask = (q, v) => (typeof window !== 'undefined' && window.prompt ? window.prompt(q, v || '') : null);
281
295
  let dataMod = null;
@@ -494,10 +508,198 @@ export function skillDetailLive(det) {
494
508
 
495
509
  /* ── knowledge ──────────────────────────────────────────────────────────────────────────────── */
496
510
  function flatten(node, out, depth) {
497
- for (const d of node.docs || []) out.push({ path: d.path, name: d.name, depth });
511
+ for (const d of node.docs || []) out.push({ path: d.path, name: d.name, depth, category: d.category || null, basis: d.category_basis || null, why: d.category_why || null, kind: d.category_kind || null, jev: d.category_jev || null, disagrees: d.category_disagrees || [] });
498
512
  if (depth < 3) for (const sub of node.dirs || []) { out.push({ dir: sub.name || sub.path, depth }); flatten(sub, out, depth + 1); }
499
513
  return out;
500
514
  }
515
+ /* jev-doc-categorizer Run 1 — each document's category, derived locally beside its own `type`, with the basis that decided it.
516
+ * `undetermined` is its own value: the file has no evidence, and it is shown as that, never as `other`. A press on a category
517
+ * narrows the list; a second press on it shows everything again. Nothing is written. */
518
+ let knCat = '';
519
+ const catTitle = (it) => it.category ? 'category ' + it.category + (it.kind ? ' (' + it.kind + ')' : '') + (it.basis ? ' · decided by ' + it.basis : ' · no evidence') + (it.why ? ' (' + it.why + ')' : '')
520
+ + (it.disagrees && it.disagrees.length ? ' · ' + it.disagrees.map((d) => d.basis + ' says ' + d.category).join(', ') : '') + ' · advisory' : '';
521
+ const catChip = (it) => it.category ? el('span', { 'data-cat': it.category, 'data-cat-basis': it.basis || '', title: catTitle(it), style: 'margin-left:8px;font-size:9.5px;letter-spacing:.06em;color:var(--muted-foreground)' }, iv('· ' + it.category + (it.kind ? ' (' + it.kind + ')' : '') + (it.basis === 'jev' ? ' (jev)' : it.jev === 'unsure' ? ' (jev unsure)' : ''))) : null;
522
+ function categoryBar(trees) {
523
+ const counts = {};
524
+ for (const t of trees) for (const it of flatten(t, [], 0)) if (it.path && it.category) counts[it.category] = (counts[it.category] || 0) + 1;
525
+ const names = Object.keys(counts).sort((a, b) => counts[b] - counts[a]);
526
+ if (!names.length) return null;
527
+ return el('div', { 'data-cat-bar': '1', style: 'display:flex;flex-wrap:wrap;gap:4px;padding:2px 14px 6px' },
528
+ names.map((n) => el('button', { 'data-cat-filter': n, 'aria-pressed': knCat === n ? 'true' : 'false', title: 'show only ' + n + ' documents', onClick: () => { knCat = knCat === n ? '' : n; setState({}); },
529
+ style: 'font-family:var(--mono);font-size:10px;padding:2px 7px;border:1px solid var(--border);' + (knCat === n ? 'background:var(--accent);' : '') }, iv(n + ' ' + counts[n]))));
530
+ }
531
+ /* D82 — Jev on documents the local rules leave undetermined. The switch is off until the PM turns it on; the first run shows
532
+ * its request count and sends only after the PM confirms that number; afterwards opening this list sends only new or changed
533
+ * files. Jev's categories are marked "(jev)" and are advisory. */
534
+ let dPlan = null; // the plan the PM is looking at: { ws, plan }
535
+ let dNote = null;
536
+ const dSay = (text) => { dNote = text; setState({}); };
537
+ async function dSwitch(ws, on) {
538
+ const out = await post('/api/doccat/jev', { ws, categorize: on });
539
+ reload('library:' + ws); dPlan = null;
540
+ dSay(out && out.ok ? (on ? 'categorizing with Jev is on — review the first run’s count, then confirm it; nothing is sent before that' : 'categorizing with Jev is off') : ((out && out.error) || 'not saved'));
541
+ }
542
+ async function dResume(ws) {
543
+ const out = await post('/api/doccat/jev/resume', { ws });
544
+ reload('library:' + ws);
545
+ dSay(out && out.ok ? 'categorizing with Jev resumed' : ((out && out.error) || 'not resumed'));
546
+ }
547
+ async function dPlanFor(ws) {
548
+ const out = await post('/api/doccat/jev/run', { ws });
549
+ if (!out || out.ok === false) { dPlan = null; return dSay((out && out.error) || 'no plan'); }
550
+ dPlan = { ws, plan: out.plan }; dSay('review the count, then confirm to send');
551
+ }
552
+ async function dRun() {
553
+ if (!dPlan) return;
554
+ const { ws, plan } = dPlan;
555
+ dSay('sending ' + plan.requests + ' request(s) to ' + plan.host + ' …');
556
+ const out = await post('/api/doccat/jev/run', { ws, confirm: true, expect_requests: plan.requests, expect_fingerprint: plan.fingerprint });
557
+ dPlan = null; reload('library:' + ws);
558
+ dSay(!out || out.ok === false ? ((out && out.error) || 'the run failed') : 'asked ' + out.sent + ' · ' + out.decided + ' categorized · ' + out.unsure + ' unsure · ' + (out.unreadable || 0) + ' unreadable · ' + out.cached + ' answered from the cache · ' + out.failed + ' failed');
559
+ }
560
+ /* Run 4 — what the last pass did to each undetermined file, so the PM can see which ones Jev categorized, which stayed
561
+ * undetermined and why, and which failed. Problems first. The record is the server's (per user, paths and outcomes only). */
562
+ let dFilesAll = false;
563
+ const D_ORDER = ['failed', 'not-asked', 'unreadable', 'unsure', 'withheld', 'excluded', 'categorized'];
564
+ const D_LABEL = { failed: 'failed', 'not-asked': 'not asked', unreadable: 'unreadable', unsure: 'unsure', withheld: 'withheld', excluded: 'excluded', categorized: 'categorized' };
565
+ const D_TONE = { failed: 'var(--destructive)', 'not-asked': 'var(--destructive)', unreadable: 'var(--destructive)', categorized: 'var(--foreground)' };
566
+ const KEEPS = ' The file keeps the local result: undetermined.';
567
+ function dOutcomeWords(f) {
568
+ const cached = f.cached ? ' Answer from the cache.' : '';
569
+ if (f.outcome === 'categorized') return 'Jev: ' + (f.category === 'ticket' ? 'ticket (' + (f.ticket_kind || 'other') + ')' : f.category) + '. ' + (f.sentence || '') + cached;
570
+ if (f.outcome === 'unsure') return 'Jev was unsure (' + (f.leading || []).join(' or ') + ').' + KEEPS + cached;
571
+ if (f.outcome === 'unreadable') return 'Jev’s answer could not be read.' + KEEPS + ' It is asked again only when the file changes.' + cached;
572
+ if (f.outcome === 'failed') return 'The request failed: ' + (f.reason || 'no reason given') + '.' + KEEPS + ' It is asked again on the next pass.';
573
+ if (f.outcome === 'withheld') return 'Not sent: the credential scan matched (' + (f.rules || []).join(', ') + ').';
574
+ if (f.outcome === 'excluded') return 'Not sent: the path is excluded.';
575
+ if (f.outcome === 'not-asked') return 'Not asked: ' + (f.reason || 'the pass stopped first');
576
+ return f.outcome;
577
+ }
578
+ function dLibraryLine(trees) {
579
+ let local = 0, jev = 0, undet = 0;
580
+ for (const t of trees || []) for (const it of flatten(t, [], 0)) {
581
+ if (!it.path || !it.category) continue;
582
+ if (it.category === 'undetermined') undet++; else if (it.basis === 'jev') jev++; else local++;
583
+ }
584
+ if (!local && !jev && !undet) return null;
585
+ return el('div', { 'data-doccat': 'library-line', style: NOTE }, iv('In this list: ' + local + ' decided by the local rules or your corrections · ' + jev + ' categorized by Jev · ' + undet + ' undetermined.'));
586
+ }
587
+ function dResultsBlock(ws, res) {
588
+ if (!res || !Array.isArray(res.files)) return [];
589
+ const counts = {};
590
+ for (const f of res.files) counts[f.outcome] = (counts[f.outcome] || 0) + 1;
591
+ const parts = D_ORDER.filter((k) => counts[k]).map((k) => counts[k] + ' ' + D_LABEL[k]);
592
+ const when = String(res.at || '').replace('T', ' ').slice(0, 16);
593
+ const out = [el('div', { 'data-doccat': 'results-summary', style: NOTE + ';color:var(--foreground)' },
594
+ iv('Last Jev pass (' + (res.kind === 'confirmed' ? 'confirmed by you' : 'automatic') + ', ' + when + '), ' + res.total + ' undetermined file(s): ' + (parts.join(' · ') || 'none') + '.'
595
+ + (res.total > res.files.length ? ' Only the first ' + res.files.length + ' are listed.' : '')))];
596
+ const rows = res.files.slice().sort((a, b) => D_ORDER.indexOf(a.outcome) - D_ORDER.indexOf(b.outcome) || a.doc.localeCompare(b.doc));
597
+ const shown = dFilesAll ? rows : rows.slice(0, 50);
598
+ for (const f of shown) {
599
+ out.push(el('div', { 'data-doccat-file': f.doc, 'data-outcome': f.outcome, style: 'display:flex;gap:8px;align-items:baseline;padding:2px 14px 2px 20px;font-size:11px' },
600
+ el('span', { 'data-doccat-outcome': f.outcome, style: 'flex:none;width:76px;font-family:var(--mono);font-size:9.5px;letter-spacing:.06em;text-transform:uppercase;color:' + (D_TONE[f.outcome] || 'var(--muted-foreground)') }, iv(D_LABEL[f.outcome] || f.outcome)),
601
+ el('span', { style: 'flex:1 1 auto;min-width:0' },
602
+ el('button', { style: 'font-family:var(--mono);font-size:11px;text-decoration:underline;color:var(--foreground);text-align:left', onClick: () => openDocIn(null, f.doc) }, iv(f.doc)),
603
+ el('span', { 'data-doccat-why': '1', style: 'display:block;color:var(--muted-foreground)' }, iv(dOutcomeWords(f))))));
604
+ }
605
+ if (rows.length > 50) out.push(el('div', { style: 'padding:2px 14px 6px 20px' },
606
+ el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-doccat': 'files-all', onClick: () => { dFilesAll = !dFilesAll; setState({}); } }, dFilesAll ? 'show the first 50' : 'show all ' + rows.length)));
607
+ return out;
608
+ }
609
+ function doccatBlock(ws, j, trees) {
610
+ if (!ws || !j) return [];
611
+ const kids = [];
612
+ kids.push(el('div', { 'data-doccat': 'explain', style: NOTE }, iv('Files with no local evidence are “undetermined”. Jev can be asked to categorize them from a short snippet: the title, the headings and the opening, never the whole file. Its answers are marked (jev), are advisory, and an unsure answer stays undetermined.')));
613
+ kids.push(el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:2px 14px 6px' },
614
+ el('button', { style: BTN + (j.switch ? ';color:var(--foreground);border-color:var(--foreground)' : ''), hover: 'color:var(--foreground)', 'data-doccat': 'switch', onClick: () => dSwitch(ws, !j.switch) },
615
+ iv(j.switch ? 'categorize with Jev: ON — turn off' : 'categorize with Jev: OFF — turn on')),
616
+ el('button', { style: BTN, hover: 'color:var(--foreground)', disabled: j.open ? null : 'true', 'data-doccat': 'plan',
617
+ title: 'Shows how many requests the undetermined files would take, and sends only after you confirm that number.', onClick: () => dPlanFor(ws) }, 'ask Jev about undetermined files'),
618
+ dPlan && dPlan.ws === ws ? el('button', { style: BTN + ';color:var(--foreground);border-color:var(--foreground)', hover: 'background:var(--accent)', 'data-doccat': 'run', onClick: dRun }, 'confirm and send ' + dPlan.plan.requests + ' request(s)') : null));
619
+ kids.push(el('div', { 'data-doccat': 'gate', 'data-open': j.open ? 'yes' : 'no', style: NOTE + (j.open ? '' : ';color:var(--foreground)') },
620
+ iv(j.open ? (j.confirmed ? 'Active: new or changed undetermined files are sent when the workspace reads this repository’s documents (when it starts, and when this list opens), at most once a minute.' : 'Ready: nothing is sent until you confirm the first run’s count.') : 'Not active: ' + j.sentence + ' All three must hold before anything is sent.')));
621
+ if (j.paused) kids.push(el('div', { 'data-doccat': 'paused', style: 'display:flex;gap:8px;align-items:center;padding:2px 14px 6px;font-size:11.5px;color:var(--destructive)' },
622
+ el('span', null, iv(j.paused.reason)),
623
+ el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-doccat': 'resume', onClick: () => dResume(ws) }, 'resume')));
624
+ const l = j.last;
625
+ if (l && l.waiting) kids.push(el('div', { 'data-doccat': 'waiting', style: NOTE + ';color:var(--foreground)' }, iv(l.waiting + ' new or changed file(s) are waiting: more than an automatic pass sends. Ask Jev and confirm the count to send them.')));
626
+ else if (l && !j.results) kids.push(el('div', { 'data-doccat': 'last', style: NOTE }, iv('Last Jev pass ' + String(l.at).replace('T', ' ').slice(0, 16) + ': sent ' + l.sent + ' · categorized ' + l.decided + ' · unsure ' + l.unsure + ' · unreadable ' + (l.unreadable || 0) + ' · withheld ' + (l.withheld_count || 0) + ' · excluded ' + l.excluded + ' · failed ' + l.failed + '.')));
627
+ if (dPlan && dPlan.ws === ws) {
628
+ const p = dPlan.plan;
629
+ kids.push(el('div', { 'data-doccat': 'plan-text', style: NOTE }, iv(p.requests + ' request(s), about ' + p.characters.toLocaleString() + ' characters, to ' + p.host + ' · ' + p.candidates + ' undetermined file(s) · ' + p.cached + ' already answered · ' + p.excluded + ' excluded · ' + p.withheld_count + ' withheld by the credential scan.')));
630
+ }
631
+ if (dNote) kids.push(el('div', { 'data-doccat': 'note', style: NOTE }, iv(dNote)));
632
+ const line = dLibraryLine(trees); if (line) kids.push(line);
633
+ kids.push(...dResultsBlock(ws, j.results));
634
+ return kids;
635
+ }
636
+ /* jev-doc-categorizer Run 3 (C7) — the PM corrects a category. `fix` on a row opens a small control: a category, a ticket kind for
637
+ * a ticket, `save correction` and `clear correction`. A correction is kept per user, outside the repository, and never edits the
638
+ * file. `write it into the file` is a separate press: it shows the one-line diff first, and `apply` writes it only if the file is
639
+ * still the text the diff was made from (the server compares the sha1 and answers 409 when it is not). The server checks every
640
+ * value again; these two lists only fill the menus. */
641
+ const CATS = ['prd', 'meeting-notes', 'report', 'ticket', 'knowledge', 'spec-artifact', 'other'];
642
+ const KINDS = ['design', 'engineering', 'other'];
643
+ let knFix = null; // { path, category, ticket_kind } — the row whose control is open
644
+ let knPlan = null; // { path, category, ticket_kind, diff, sha, unchanged } — the write-back diff on screen
645
+ let knNote = null; // { path, tone, text }
646
+ let knBusy = false;
647
+ const FIX = 'font-family:var(--mono);font-size:9.5px;padding:1px 6px;margin-right:10px;border:1px solid var(--border);color:var(--muted-foreground);flex:none';
648
+ const SEL = 'font-family:var(--mono);font-size:10.5px;padding:2px 4px;border:1px solid var(--border);background:var(--background);color:var(--foreground)';
649
+ const knSay = (path, tone, text) => { knNote = { path, tone, text }; knBusy = false; setState({}); };
650
+ function knOpen(it) {
651
+ if (knFix && knFix.path === it.path) { knFix = null; knPlan = null; knNote = null; setState({}); return; }
652
+ const cat = CATS.includes(it.category) ? it.category : 'other';
653
+ knFix = { path: it.path, category: cat, ticket_kind: cat === 'ticket' ? (it.kind || 'other') : null };
654
+ knPlan = null; knNote = null; setState({});
655
+ }
656
+ async function knSave(ws, clear) {
657
+ if (!knFix || knBusy) return;
658
+ knBusy = true; setState({});
659
+ const body = clear ? { ws, path: knFix.path, clear: true } : { ws, path: knFix.path, category: knFix.category, ticket_kind: knFix.category === 'ticket' ? knFix.ticket_kind : null };
660
+ const res = await post('/api/doccat/correct', body);
661
+ refresh('library:' + ws);
662
+ if (res && res.ok) knSay(knFix.path, 'ok', clear ? (res.cleared ? 'correction cleared — the rules decide this file again' : 'this file had no correction') : 'saved — your correction wins over every rule. The file is not changed.');
663
+ else knSay(knFix.path, 'bad', (res && res.error) || 'not saved');
664
+ }
665
+ async function knWriteback(ws, confirm) {
666
+ if (!knFix || knBusy) return;
667
+ knBusy = true; setState({});
668
+ const want = { category: knFix.category, ticket_kind: knFix.category === 'ticket' ? knFix.ticket_kind : null };
669
+ if (!confirm) {
670
+ const res = await post('/api/doccat/writeback', { ws, path: knFix.path, ...want });
671
+ if (res && res.ok) { knPlan = { path: knFix.path, ...want, diff: res.diff, sha: res.sha, next_sha: res.next_sha, unchanged: !!res.unchanged }; knSay(knFix.path, 'mut', res.unchanged ? 'the file already says this — nothing to write' : 'this is the change. Press apply to write it into the file.'); }
672
+ else { knPlan = null; knSay(knFix.path, 'bad', (res && res.error) || 'no diff'); }
673
+ return;
674
+ }
675
+ if (!knPlan) { knBusy = false; return; }
676
+ const res = await post('/api/doccat/writeback', { ws, path: knPlan.path, category: knPlan.category, ticket_kind: knPlan.ticket_kind, confirm: true, expect_sha: knPlan.sha, expect_next_sha: knPlan.next_sha });
677
+ knPlan = null;
678
+ refresh('library:' + ws);
679
+ if (res && res.ok) knSay(knFix.path, 'ok', res.written ? 'written into the file\'s frontmatter' : 'the file already says this — nothing was written');
680
+ else knSay(knFix.path, 'bad', (res && res.error) || 'not written');
681
+ }
682
+ function knPanel(ws, it) {
683
+ const f = knFix;
684
+ const pad = 'padding:2px 14px 8px ' + (20 + it.depth * 12) + 'px';
685
+ const btn = (attr, label, fn, disabled) => el('button', { [attr]: '1', disabled: knBusy || disabled, onClick: fn, style: BTN, hover: 'color:var(--foreground)' }, label);
686
+ const kids = [
687
+ el('select', { 'data-cat-select': '1', 'aria-label': 'category', style: SEL, onChange: (e) => { f.category = e.target.value; f.ticket_kind = f.category === 'ticket' ? (f.ticket_kind || 'other') : null; knPlan = null; setState({}); } },
688
+ CATS.map((c) => el('option', { value: c, selected: c === f.category }, c))),
689
+ f.category === 'ticket' ? el('select', { 'data-cat-kind': '1', 'aria-label': 'ticket kind', style: SEL, onChange: (e) => { f.ticket_kind = e.target.value; knPlan = null; setState({}); } },
690
+ KINDS.map((k) => el('option', { value: k, selected: k === f.ticket_kind }, k))) : null,
691
+ btn('data-cat-save', 'save correction', () => knSave(ws, false)),
692
+ it.basis === 'pm correction' ? btn('data-cat-clear', 'clear correction', () => knSave(ws, true)) : null,
693
+ btn('data-cat-writeback', 'write it into the file', () => knWriteback(ws, false)),
694
+ ];
695
+ const out = [el('div', { 'data-cat-panel': it.path, style: pad + ';display:flex;flex-wrap:wrap;gap:6px;align-items:center' }, kids)];
696
+ if (knPlan && knPlan.path === it.path && !knPlan.unchanged) {
697
+ out.push(el('pre', { 'data-cat-diff': '1', style: pad.replace('padding:2px', 'margin:0;padding:6px') + ';font-family:var(--mono);font-size:10.5px;white-space:pre-wrap;color:var(--foreground);background:var(--accent)' }, iv(knPlan.diff)));
698
+ out.push(el('div', { style: pad + ';display:flex;gap:6px' }, btn('data-cat-apply', 'apply', () => knWriteback(ws, true)), btn('data-cat-cancel', 'cancel', () => { knPlan = null; knNote = null; setState({}); })));
699
+ }
700
+ if (knNote && knNote.path === it.path) out.push(el('div', { 'data-cat-note': knNote.tone, style: pad + ';font-size:11px;color:' + TONE[knNote.tone] }, iv(knNote.text)));
701
+ return out;
702
+ }
501
703
  export function knowledgeLive() {
502
704
  const ws = state.wsId || '';
503
705
  const r = read('library:' + ws, '/api/library?ws=' + encodeURIComponent(ws));
@@ -505,11 +707,18 @@ export function knowledgeLive() {
505
707
  if (st) return [st];
506
708
  const trees = (r.data && r.data.knowledge) || [];
507
709
  const kids = [el('div', { style: NOTE }, iv('This repository\'s knowledge: the OKF bundle, skills and build-skills. Open a document to read it.'))];
710
+ const bar = categoryBar(trees); if (bar) kids.push(bar);
711
+ kids.push(...doccatBlock(ws, r.data && r.data.doccat, trees));
508
712
  for (const t of trees) {
509
713
  kids.push(el('div', { style: LABEL }, iv(t.path || t.name || 'knowledge')));
510
714
  for (const it of flatten(t, [], 0)) {
715
+ if (knCat && (it.dir || it.category !== knCat)) continue;
511
716
  if (it.dir) { kids.push(el('div', { style: 'padding:3px 14px 1px ' + (14 + it.depth * 12) + 'px;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(it.dir + '/'))); continue; }
512
- kids.push(el('button', { onClick: () => openDocIn(null, it.path), style: 'display:block;width:100%;text-align:left;padding:4px 14px 4px ' + (20 + it.depth * 12) + 'px;font-family:var(--mono);font-size:11.5px', hover: 'background:var(--accent)', 'data-r4': 'know' }, iv(it.name)));
717
+ const open = !!(knFix && knFix.path === it.path);
718
+ kids.push(el('div', { 'data-cat-row': it.path, style: 'display:flex;align-items:center' },
719
+ el('button', { onClick: () => openDocIn(null, it.path), style: 'display:block;flex:1 1 auto;min-width:0;text-align:left;padding:4px 14px 4px ' + (20 + it.depth * 12) + 'px;font-family:var(--mono);font-size:11.5px', hover: 'background:var(--accent)', 'data-r4': 'know' }, iv(it.name), catChip(it)),
720
+ el('button', { 'data-cat-fix': it.path, 'aria-expanded': open ? 'true' : 'false', title: 'correct the category of this document', onClick: () => knOpen(it), style: FIX + (open ? ';background:var(--accent);color:var(--foreground)' : ''), hover: 'color:var(--foreground)' }, 'fix')));
721
+ if (open) kids.push(...knPanel(ws, it));
513
722
  }
514
723
  }
515
724
  // R11 — the notes PMOS and its agents wrote here (requirement notes, report drafts and definitions, memory).
@@ -557,6 +766,14 @@ async function vConsent(on) {
557
766
  reload('vrepo'); vPlan = null;
558
767
  vSay(out && out.ok ? 'ok' : 'bad', out && out.ok ? (on ? 'validation is on for this repository' : 'validation is off; nothing from this repository will be sent') : ((out && out.error) || 'not saved'));
559
768
  }
769
+ /* prompt-model-routing Run 3 — the third D84 gate: route this repository's prompts with Jev. With it on (and the key and
770
+ * consent above), the cockpit mod in each terminal switches the model for a prompt when Jev decides its tier. */
771
+ async function vRoute(on) {
772
+ const ws = vWs(); if (!ws) return vSay('bad', 'select a repository first');
773
+ const out = await post('/api/model/route/switch', { ws, route: on });
774
+ reload('vrepo');
775
+ vSay(out && out.ok ? 'ok' : 'bad', out && out.ok ? (on ? (out.open ? 'routing prompts with Jev is on: each prompt is checked before it runs' : 'routing prompts with Jev is on, but not active yet: ' + out.sentence) : 'routing prompts with Jev is off; no prompt is sent') : ((out && out.error) || 'not saved'));
776
+ }
560
777
  async function vExclude(text) {
561
778
  const ws = vWs(); if (!ws) return;
562
779
  const exclude = String(text || '').split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
@@ -614,6 +831,12 @@ async function jSwitch(on) {
614
831
  reload('claims'); jPlan = null;
615
832
  jSay(out && out.ok ? (on ? 'checking with Jev automatically is on — the first check records what exists and sends nothing; after that only changed pairs are sent' : 'checking with Jev automatically is off') : ((out && out.error) || 'not saved'));
616
833
  }
834
+ async function jStatus(on) {
835
+ const ws = vWs(); if (!ws) return;
836
+ const out = await post('/api/claims/jev/status', { ws, status_read: on });
837
+ reload('claims'); jPlan = null;
838
+ jSay(out && out.ok ? (on ? 'reading documents for status is on — the next check records the documents that exist and sends nothing for them; after that only changed documents are sent' : 'reading documents for status is off') : ((out && out.error) || 'not saved'));
839
+ }
617
840
  async function jResume() {
618
841
  const ws = vWs(); if (!ws) return;
619
842
  const out = await post('/api/claims/jev/resume', { ws });
@@ -648,7 +871,11 @@ function jevBlock(ws, d) {
648
871
  kids.push(el('div', { 'data-r24': 'explain', style: NOTE }, iv('Where the local checks cannot decide, a pair of short texts (a requirement and the rubric’s dimensions, a story section and the requirement it cites, a sentence and the decision it cites, two sections of one PRD) can be put to Jev. Only pairs that changed are sent. Jev’s answers are suspicions: advisory, shown in words, and never counted as problems.')));
649
872
  kids.push(el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:2px 14px 6px' },
650
873
  el('button', { style: BTN + (j.switch ? ';color:var(--foreground);border-color:var(--foreground)' : ''), hover: 'color:var(--foreground)', 'data-r24': 'switch', onClick: () => jSwitch(!j.switch) },
651
- iv(j.switch ? 'check with Jev automatically: ON — turn off' : 'check with Jev automatically: OFF — turn on'))));
874
+ iv(j.switch ? 'check with Jev automatically: ON — turn off' : 'check with Jev automatically: OFF — turn on')),
875
+ el('button', { style: BTN + (j.status_read ? ';color:var(--foreground);border-color:var(--foreground)' : ''), hover: 'color:var(--foreground)', 'data-r24': 'status-switch',
876
+ title: 'Also send the documents tied to each initiative (its PRD, rubric and brief, and notes that name it), to ask whether a document is a stub and whether a note contradicts a requirement. The answers are suggestions in the initiative detail. Needs the switch on the left.',
877
+ onClick: () => jStatus(!j.status_read) },
878
+ iv(j.status_read ? 'read documents for status: ON — turn off' : 'read documents for status: OFF — turn on'))));
652
879
  kids.push(el('div', { 'data-r24': 'gate', 'data-open': j.open ? 'yes' : 'no', style: NOTE + (j.open ? '' : ';color:var(--foreground)') },
653
880
  iv(j.open ? 'Active: a key is saved, validation is on for this repository, automatic checking is on and this switch is on.' : 'Not active: ' + j.sentence + ' All four must hold before anything is sent.')));
654
881
  if (j.paused) kids.push(el('div', { 'data-r24': 'paused', style: 'display:flex;gap:8px;align-items:center;padding:2px 14px 6px;font-size:11.5px;color:var(--destructive)' },
@@ -763,6 +990,10 @@ export function validateList() {
763
990
  kids.push(el('div', { style: 'display:flex;gap:8px;align-items:center;padding:4px 14px' },
764
991
  el('span', { style: 'font-size:12px', 'data-r19': 'consent-state' }, iv(on ? 'validation is ON for this repository' : 'validation is OFF — nothing from this repository is sent')),
765
992
  el('button', { style: BTN + ';margin-left:auto', hover: 'color:var(--foreground)', 'data-r19': 'consent-toggle', onClick: () => vConsent(!on) }, on ? 'turn off' : 'turn on')));
993
+ const ro = !!(rp.data && rp.data.route);
994
+ kids.push(el('div', { style: 'display:flex;gap:8px;align-items:center;padding:4px 14px' },
995
+ el('span', { style: 'font-size:12px', 'data-route': 'state' }, iv(ro ? 'route prompts with Jev: ON — each prompt typed in a workspace terminal is checked, and the model switches when Jev decides' : 'route prompts with Jev: OFF — no prompt is sent')),
996
+ el('button', { style: BTN + ';margin-left:auto', hover: 'color:var(--foreground)', 'data-route': 'toggle', onClick: () => vRoute(!ro) }, ro ? 'turn off' : 'turn on')));
766
997
  const ta = el('textarea', { rows: 2, placeholder: 'paths never sent, one per line (e.g. private/ or notes/*.md)', 'data-r19': 'exclude-input',
767
998
  style: 'width:calc(100% - 28px);margin:4px 14px 0;font-family:var(--mono);font-size:10.5px;padding:4px 8px;border:1px solid var(--border);background:var(--background);color:var(--foreground)' }, (rp.data.exclude || []).join('\n'));
768
999
  kids.push(ta, el('div', { style: 'padding:4px 14px 6px' }, el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r19': 'exclude-save', onClick: () => vExclude(ta.value) }, 'save excluded paths')));
@@ -810,6 +1041,35 @@ export function validateList() {
810
1041
  /* ── Today (proactive-engine D1): what is waiting, computed from the ledgers, with feedback ─────────────────────── */
811
1042
  let tNote = null;
812
1043
  let tMissed = false;
1044
+ /* The text inputs of the Today list are kept across redraws. The list is drawn again on every state change (the 4-second retry of
1045
+ * an unread repository is one), and a new input on each draw threw away what the person was typing and the caret with it. Each
1046
+ * input is made once; a redraw writes the stored value into it only while the person has not edited it since the last save. */
1047
+ const tInputs = new Map();
1048
+ function tInput(key, attrs, stored) {
1049
+ let n = tInputs.get(key);
1050
+ if (!n) {
1051
+ n = el('input', { ...attrs, onInput: (e) => { e.target.dataset.dirty = '1'; } });
1052
+ tInputs.set(key, n);
1053
+ }
1054
+ if (!n.dataset.dirty && n.value !== stored) n.value = stored;
1055
+ // The redraw moves the input into a new row, and moving a node blurs it: give the focus and the caret back once the draw is done.
1056
+ if (typeof document !== 'undefined' && document.activeElement === n) {
1057
+ let a = null, b = null;
1058
+ try { a = n.selectionStart; b = n.selectionEnd; } catch { /* a number input has no selection */ }
1059
+ queueMicrotask(() => {
1060
+ if (!n.isConnected || document.activeElement === n) return;
1061
+ n.focus();
1062
+ try { if (a !== null) n.setSelectionRange(a, b); } catch { /* as above */ }
1063
+ });
1064
+ }
1065
+ return n;
1066
+ }
1067
+ /** Save a settings patch from kept inputs; on success their text is the stored value again. */
1068
+ async function tSaveFrom(inputs, patch) {
1069
+ const out = await tSettings(patch);
1070
+ if (out && out.ok !== false) for (const n of inputs) delete n.dataset.dirty;
1071
+ setState({});
1072
+ }
813
1073
  let tRetry = null;
814
1074
  const tSay = (tone, text) => { tNote = { tone, text }; setState({}); };
815
1075
  const OWNER_WORD = { pm: 'yours', agent: 'the agent\'s' };
@@ -834,6 +1094,7 @@ async function tSettings(patch) {
834
1094
  const out = await post('/api/proactive/settings', patch);
835
1095
  reload('today'); reload('brief'); reload('prepare'); reload('learn');
836
1096
  tSay(!out || out.ok === false ? 'bad' : 'ok', !out || out.ok === false ? ((out && out.error) || 'not saved') : 'saved');
1097
+ return out;
837
1098
  }
838
1099
  function tCard(c) {
839
1100
  const b = (label, attr, fn) => el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r22': attr, onClick: fn }, label);
@@ -1011,7 +1272,7 @@ function learningSection() {
1011
1272
  kids.push(el('div', { style: NOTE, 'data-r26': 'last-run' }, iv(last ? 'Last weekly review: ' + String(last.at).replace('T', ' ').slice(0, 16) + ' UTC. It runs about every 7 days, only while the workspace server runs, and does not catch up.' : 'No weekly review has run yet. It runs about every 7 days once there is feedback, only while the workspace server runs, and does not catch up.')));
1012
1273
  kids.push(el('div', { style: 'display:flex;gap:4px;flex-wrap:wrap;padding:2px 14px 6px' },
1013
1274
  b('run the weekly review now', 'run', () => lPost('/api/proactive/learn/run', {}, (o) => 'review taken — ' + o.proposed.length + ' lesson(s) proposed'), 'refused while learning is paused'),
1014
- b('forget what it learned', 'reset', () => lPost('/api/proactive/learn/reset', { confirm: true }, 'reset — earlier feedback is ignored from now on, not deleted'), 'earlier feedback stops counting; nothing is deleted')));
1275
+ b('forget what it learned', 'reset', () => { if (typeof window !== 'undefined' && window.confirm && !window.confirm('Forget what it learned? Earlier feedback stops counting. Nothing is deleted.')) return; lPost('/api/proactive/learn/reset', { confirm: true }, 'reset — earlier feedback is ignored from now on, not deleted'); }, 'earlier feedback stops counting; nothing is deleted')));
1015
1276
  return kids;
1016
1277
  }
1017
1278
  export function todayList() {
@@ -1024,9 +1285,9 @@ export function todayList() {
1024
1285
  el('button', { style: BTN, hover: 'color:var(--foreground)', title: 'read the brief and the list again', 'data-r22': 'refresh', onClick: () => { reload('today'); reload('brief'); reload('learn'); setState({}); } }, '↻ refresh'),
1025
1286
  el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r22': 'missed', onClick: () => { tMissed = !tMissed; setState({}); } }, 'something was missed')));
1026
1287
  if (tMissed) {
1027
- const ta = el('input', { type: 'text', placeholder: 'what should it have shown you?', 'data-r22': 'missed-input', style: 'flex:1;min-width:0;font-size:11.5px;padding:4px 8px;border:1px solid var(--border);background:var(--background);color:var(--foreground);min-height:28px' });
1288
+ const ta = tInput('missed', { type: 'text', placeholder: 'what should it have shown you?', 'data-r22': 'missed-input', style: 'flex:1;min-width:0;font-size:11.5px;padding:4px 8px;border:1px solid var(--border);background:var(--background);color:var(--foreground);min-height:28px' }, '');
1028
1289
  kids.push(el('div', { style: 'display:flex;gap:4px;padding:2px 14px 6px' }, ta,
1029
- el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r22': 'missed-save', onClick: async () => { await tFeedback({ verdict: 'missed', note: ta.value }, 'recorded as missed'); if (tNote && tNote.tone === 'ok') tMissed = false; setState({}); } }, 'save')));
1290
+ el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r22': 'missed-save', onClick: async () => { await tFeedback({ verdict: 'missed', note: ta.value }, 'recorded as missed'); if (tNote && tNote.tone === 'ok') { tMissed = false; delete ta.dataset.dirty; } setState({}); } }, 'save')));
1030
1291
  }
1031
1292
  if (tNote) kids.push(el('div', { 'data-r22': 'note', style: NOTE + ';color:' + (tNote.tone === 'bad' ? 'var(--destructive)' : tNote.tone === 'ok' ? 'var(--foreground)' : 'var(--muted-foreground)') }, iv(tNote.text)));
1032
1293
  const st = status(r, 'Today');
@@ -1053,16 +1314,16 @@ export function todayList() {
1053
1314
  kids.push(el('div', { style: LABEL }, iv('morning brief')));
1054
1315
  for (const k of Object.keys(s.briefSources || {})) kids.push(chk('read ' + k + (k === 'mail' ? ' (titles and senders only)' : ''), s.briefSources[k], 'set-src-' + k, (v) => ({ briefSources: { [k]: v } })));
1055
1316
  kids.push(chk('typed scoring (sends titles and metadata to the Typesafe service)', s.briefScoring, 'set-scoring', (v) => ({ briefScoring: v })));
1056
- const nm = el('input', { type: 'text', maxlength: '40', placeholder: 'your name for the greeting', value: s.name || '', 'data-r22': 'set-name', style: 'width:180px;font-size:11.5px;padding:2px 6px;border:1px solid var(--border);background:var(--background);color:var(--foreground)' });
1057
- kids.push(el('div', { style: 'display:flex;gap:6px;align-items:center;padding:4px 14px;font-size:12px' }, nm, el('button', { style: BTN, 'data-r22': 'set-name-save', onClick: () => tSettings({ name: nm.value }) }, 'save')));
1317
+ const nm = tInput('name', { type: 'text', maxlength: '40', placeholder: 'your name for the greeting', 'data-r22': 'set-name', style: 'width:180px;font-size:11.5px;padding:2px 6px;border:1px solid var(--border);background:var(--background);color:var(--foreground)' }, s.name || '');
1318
+ kids.push(el('div', { style: 'display:flex;gap:6px;align-items:center;padding:4px 14px;font-size:12px' }, nm, el('button', { style: BTN, 'data-r22': 'set-name-save', onClick: () => tSaveFrom([nm], { name: nm.value }) }, 'save')));
1058
1319
  kids.push(...learningSection());
1059
1320
  kids.push(chk('pause learning (no weights, no lessons, nothing narrowed)', s.pauseLearning, 'set-pause', (v) => ({ pauseLearning: v })));
1060
- const days = el('input', { type: 'number', min: '1', max: '90', value: String(s.stalledDays), 'data-r22': 'set-stalled-days', style: 'width:60px;font-size:11.5px;padding:2px 6px;border:1px solid var(--border);background:var(--background);color:var(--foreground)' });
1061
- const qs = el('input', { type: 'text', placeholder: '22:00', value: s.quiet ? s.quiet.start : '', 'data-r22': 'set-quiet-start', style: 'width:56px;font-size:11.5px;padding:2px 6px;border:1px solid var(--border);background:var(--background);color:var(--foreground)' });
1062
- const qe = el('input', { type: 'text', placeholder: '07:00', value: s.quiet ? s.quiet.end : '', 'data-r22': 'set-quiet-end', style: 'width:56px;font-size:11.5px;padding:2px 6px;border:1px solid var(--border);background:var(--background);color:var(--foreground)' });
1321
+ const days = tInput('stalled', { type: 'number', min: '1', max: '90', 'data-r22': 'set-stalled-days', style: 'width:60px;font-size:11.5px;padding:2px 6px;border:1px solid var(--border);background:var(--background);color:var(--foreground)' }, String(s.stalledDays));
1322
+ const qs = tInput('quiet-start', { type: 'text', placeholder: '22:00', 'data-r22': 'set-quiet-start', style: 'width:56px;font-size:11.5px;padding:2px 6px;border:1px solid var(--border);background:var(--background);color:var(--foreground)' }, s.quiet ? s.quiet.start : '');
1323
+ const qe = tInput('quiet-end', { type: 'text', placeholder: '07:00', 'data-r22': 'set-quiet-end', style: 'width:56px;font-size:11.5px;padding:2px 6px;border:1px solid var(--border);background:var(--background);color:var(--foreground)' }, s.quiet ? s.quiet.end : '');
1063
1324
  kids.push(el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:4px 14px;font-size:12px' }, iv('stalled after'), days, iv('days'),
1064
- el('button', { style: BTN, 'data-r22': 'set-stalled-save', onClick: () => tSettings({ stalledDays: Number(days.value) }) }, 'save')));
1325
+ el('button', { style: BTN, 'data-r22': 'set-stalled-save', onClick: () => tSaveFrom([days], { stalledDays: Number(days.value) }) }, 'save')));
1065
1326
  kids.push(el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:4px 14px 8px;font-size:12px' }, iv('quiet hours'), qs, iv('to'), qe,
1066
- el('button', { style: BTN, 'data-r22': 'set-quiet-save', onClick: () => tSettings({ quiet: qs.value.trim() || qe.value.trim() ? { start: qs.value.trim(), end: qe.value.trim() } : null }) }, 'save')));
1327
+ el('button', { style: BTN, 'data-r22': 'set-quiet-save', onClick: () => tSaveFrom([qs, qe], { quiet: qs.value.trim() || qe.value.trim() ? { start: qs.value.trim(), end: qe.value.trim() } : null }) }, 'save')));
1067
1328
  return [el('div', { style: 'padding:2px 0 8px', 'data-r22': 'today' }, kids)];
1068
1329
  }