create-pmos 0.47.0 → 0.48.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 (71) 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 +1 -1
  5. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
  6. package/payload/mods/pmos-cockpit/README.md +4 -2
  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/register.tsx +37 -18
  11. package/payload/planning/evals/eval-calibration.md +1 -1
  12. package/payload/planning/okrs/health-budgets.md +1 -1
  13. package/payload/scripts/pmos-log.sh +18 -8
  14. package/payload/scripts/pmos-model.js +233 -2
  15. package/payload/scripts/status-facts.js +204 -0
  16. package/payload/scripts/workflow-state.js +52 -0
  17. package/payload/state/discoveries.md +1 -1
  18. package/workspace/README.md +2 -2
  19. package/workspace/bin/live-notes.js +133 -0
  20. package/workspace/design-parity.js +328 -21
  21. package/workspace/doc-category-bench.js +165 -0
  22. package/workspace/fixtures/doc-category-labelled.json +167 -0
  23. package/workspace/guide/initiatives-and-next-steps.md +15 -2
  24. package/workspace/guide/knowledge-graph.md +83 -1
  25. package/workspace/guide/layout.md +29 -2
  26. package/workspace/guide/proactive-engine.md +6 -2
  27. package/workspace/guide/validate-with-typesafe.md +16 -1
  28. package/workspace/lib/claims.js +51 -2
  29. package/workspace/lib/doc-category-corrections.js +113 -0
  30. package/workspace/lib/doc-category-writeback.js +110 -0
  31. package/workspace/lib/doc-category.js +225 -0
  32. package/workspace/lib/engine/pmos-model.js +233 -2
  33. package/workspace/lib/engine/status-facts.js +204 -0
  34. package/workspace/lib/engine/workflow-state.js +52 -0
  35. package/workspace/lib/jev-categorize.js +399 -0
  36. package/workspace/lib/jev-claims.js +59 -15
  37. package/workspace/lib/jev-route.js +129 -0
  38. package/workspace/lib/kg-cli.js +3 -1
  39. package/workspace/lib/kg.js +6 -1
  40. package/workspace/lib/learn.js +5 -3
  41. package/workspace/lib/live-notes.js +890 -0
  42. package/workspace/lib/typesafe-settings.js +10 -2
  43. package/workspace/package-lock.json +1 -1
  44. package/workspace/package.json +5 -4
  45. package/workspace/public/pm/app.js +148 -79
  46. package/workspace/public/pm/data.js +58 -0
  47. package/workspace/public/pm/fixture.js +40 -2
  48. package/workspace/public/pm/modes-live.js +216 -18
  49. package/workspace/public/pm/panel-cockpit.js +636 -0
  50. package/workspace/public/pm/panel-detail.js +210 -8
  51. package/workspace/public/pm/panel-initiatives.js +9 -9
  52. package/workspace/public/pm/panel-newsess.js +145 -86
  53. package/workspace/public/pm/panel-repos.js +34 -17
  54. package/workspace/public/pm/panel-tabs.js +145 -27
  55. package/workspace/public/pm/state.js +45 -4
  56. package/workspace/public/pm/tab-doc.js +244 -16
  57. package/workspace/public/pm/tab-term.js +11 -1
  58. package/workspace/public/pm/tokens.css +4 -3
  59. package/workspace/public/pm/view-addrepo.js +207 -0
  60. package/workspace/public/pm/view-files.js +197 -0
  61. package/workspace/public/pm/view-guide.js +141 -0
  62. package/workspace/public/pm/view-knowledge.js +225 -0
  63. package/workspace/public/pm/view-memory.js +122 -0
  64. package/workspace/public/pm/view-portfolio.js +153 -0
  65. package/workspace/public/pm/view-reports.js +190 -0
  66. package/workspace/public/pm/view-scheduled.js +161 -0
  67. package/workspace/public/pm/view-skills.js +216 -0
  68. package/workspace/public/pm/view-today.js +168 -0
  69. package/workspace/public/pm/view-validate.js +171 -0
  70. package/workspace/server.js +290 -7
  71. package/workspace/skills/live-notes.skill +58 -0
@@ -0,0 +1,171 @@
1
+ /* pm/view-validate.js — the Validate view, drawn from planning/design/cockpit-mockup/ViewValidate.dc.html.
2
+ *
3
+ * PRD workspace-cockpit-nav, Run 3 (R4). The board is a header with two actions on the right, a row of five stat cards, a
4
+ * table of findings and a closing note. The content is the view's existing list (modes-live.js `validateList`): this module
5
+ * draws it into a host and arranges its parts into the board. No node is removed and no text is changed, so every `data-r19`,
6
+ * `data-r22` and `data-r24` hook and every live behaviour stays as it was. Only the frame is new.
7
+ *
8
+ * Where each part comes from:
9
+ * - The two header actions are the list's own buttons, moved: "check now" (`data-r22="check"`, the local consistency check)
10
+ * and "show what would be sent" (`data-r19="plan"`, the first step of a TypeSafe run). Their labels are kept because the
11
+ * verdict line and the guide page name them.
12
+ * - The stat cards count the findings of the last TypeSafe run by rule (`broken-link`, `stale-after-passed`,
13
+ * `workflow-sweep`, `withheld-by-secret-scan`), read off the rows the list drew, so a card and the table always agree.
14
+ * The consent card reads `data-r19="consent-state"`. When no run exists, or the read has not answered, a card says so.
15
+ * - The table is the run's finding rows (`data-r19="finding"`), restyled as a grid. The board's "Status" column has no
16
+ * backing (a finding has no open or closed state), so the table shows the finding's severity instead.
17
+ * - The local consistency check and the TypeSafe settings have no place on the board. They are kept below the table as
18
+ * two cards, unchanged inside.
19
+ * In the fixture the list is empty, and the board says that validation is not read there.
20
+ */
21
+ import { el } from './dom.js';
22
+
23
+ const BTN = 'font:500 12.5px var(--sans);background:transparent;color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:0 12px;height:30px;min-height:30px;display:inline-grid;place-items:center;cursor:pointer;flex:none';
24
+ const BTNP = 'font:500 12.5px var(--sans);background:var(--foreground);color:var(--background);border:0;border-radius:8px;padding:0 12px;height:30px;min-height:30px;display:inline-grid;place-items:center;cursor:pointer;flex:none';
25
+ const BADGE = 'font:500 11px var(--sans);border:1px solid var(--border);border-radius:999px;padding:0 8px;line-height:18px;white-space:nowrap;display:inline-block';
26
+ const TONE = {
27
+ s: ';border-color:transparent;background:color-mix(in oklab, var(--success) 16%, transparent);color:var(--success)',
28
+ w: ';border-color:transparent;background:color-mix(in oklab, var(--warning) 16%, transparent);color:var(--warning)',
29
+ d: ';border-color:transparent;background:color-mix(in oklab, var(--destructive) 18%, transparent);color:var(--destructive)',
30
+ };
31
+ const STAT = 'flex:1 1 150px;min-width:0;border:1px solid var(--border);background:var(--background);border-radius:10px;padding:12px 14px';
32
+ const TH = 'font:500 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground);text-align:left';
33
+ const COLS = 'display:grid;grid-template-columns:minmax(0,2.4fr) minmax(0,1fr) 92px minmax(0,1.3fr);gap:0 14px;align-items:center';
34
+ const CARD = 'border:1px solid var(--border);border-radius:10px;background:var(--background);padding:4px 0 8px;overflow:hidden';
35
+ const CARD_HEAD = 'padding:12px 14px 2px;font:600 15px var(--sans);color:var(--foreground)';
36
+ const LBL = 'font:500 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)';
37
+ const NOTE = 'color:var(--muted-foreground);font-size:13px;line-height:1.5';
38
+
39
+ const badge = (text, tone) => el('span', { style: BADGE + (tone ? TONE[tone] : '') }, String(text));
40
+
41
+ /** The board's stat cards. `kind` is what the card counts; the counts come from the rows the list drew. */
42
+ const STATS = [
43
+ { key: 'broken', label: 'Broken links', rule: 'broken-link', tone: 'var(--destructive)' },
44
+ { key: 'stale', label: 'Stale documents', rule: 'stale-after-passed', tone: 'var(--warning)' },
45
+ { key: 'workflow', label: 'Workflow defects', rule: 'workflow-sweep', tone: 'var(--warning)' },
46
+ { key: 'withheld', label: 'Withheld (credential shape)', rule: 'withheld-by-secret-scan', tone: null },
47
+ ];
48
+
49
+ /** What the list says about the last run: 'run' (the coverage line is drawn), 'none' (no run), or 'unread'. */
50
+ function runState(wrap) {
51
+ if (!wrap) return 'unread';
52
+ if (wrap.querySelector('[data-r19="coverage"]')) return 'run';
53
+ if (wrap.querySelector('[data-r19="no-run"]')) return 'none';
54
+ return 'unread';
55
+ }
56
+
57
+ function stats(wrap) {
58
+ const run = runState(wrap);
59
+ const rows = wrap ? [...wrap.querySelectorAll('[data-r19="finding"]')] : [];
60
+ const cards = STATS.map((s) => {
61
+ const n = rows.filter((r) => r.getAttribute('data-rule') === s.rule).length;
62
+ const value = run === 'run'
63
+ ? el('div', { 'data-board-value': String(n), style: 'font:600 24px var(--sans);line-height:1.3;color:' + (n > 0 && s.tone ? s.tone : 'var(--foreground)') }, String(n))
64
+ : el('div', { 'data-board-value': run, style: 'margin-top:6px' }, badge(run === 'none' ? 'no run yet' : 'not read'));
65
+ return el('div', { 'data-board-stat': s.key, style: STAT }, el('div', { style: 'color:var(--muted-foreground)' }, s.label), value);
66
+ });
67
+ const cs = wrap ? wrap.querySelector('[data-r19="consent-state"]') : null;
68
+ const txt = cs ? cs.textContent || '' : '';
69
+ const consent = !cs ? badge('not read') : /\bON\b/.test(txt) ? badge('given', 's') : badge('not given', 'w');
70
+ cards.push(el('div', { 'data-board-stat': 'consent', 'data-board-value': !cs ? 'unread' : /\bON\b/.test(txt) ? 'given' : 'not-given', style: STAT },
71
+ el('div', { style: 'color:var(--muted-foreground)' }, 'Jev consent'), el('div', { style: 'margin-top:6px' }, consent)));
72
+ return el('div', { 'data-board-part': 'stats', style: 'display:flex;gap:14px;flex-wrap:wrap' }, cards);
73
+ }
74
+
75
+ /** Split the list's children at its section labels. The labels are fixed text in modes-live.js. */
76
+ function sections(wrap) {
77
+ const kids = [...wrap.children];
78
+ const at = (re) => kids.findIndex((k) => re.test((k.textContent || '').trim()));
79
+ const iT = at(/^typesafe \(jev\)/i);
80
+ const iF = at(/^4 · findings/);
81
+ const end = (i, fallback) => (i < 0 ? fallback : i);
82
+ return {
83
+ consistency: kids.slice(0, end(iT, end(iF, kids.length))),
84
+ typesafe: iT < 0 ? [] : kids.slice(iT, end(iF, kids.length)),
85
+ findings: iF < 0 ? [] : kids.slice(iF),
86
+ };
87
+ }
88
+
89
+ /** Restyle one finding row as a table row. Only styles change: the row keeps its nodes, text, hooks and click. */
90
+ function asTableRow(row) {
91
+ row.setAttribute('style', COLS + ';width:100%;box-sizing:border-box;padding:10px 14px;text-align:left;border-bottom:1px solid var(--border);font-size:13px;color:var(--foreground);cursor:pointer;min-height:40px');
92
+ const [head, files] = row.children;
93
+ if (files) files.setAttribute('style', 'order:0;font:12px var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;color:var(--foreground)');
94
+ if (head) {
95
+ head.setAttribute('style', 'display:contents');
96
+ const [sev, rule, meta] = head.children;
97
+ if (rule) rule.setAttribute('style', 'order:1;font-size:13px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap');
98
+ if (sev) {
99
+ const v = (sev.textContent || '').trim();
100
+ const tone = v === 'high' ? TONE.d : v === 'medium' ? TONE.w : '';
101
+ sev.setAttribute('style', 'order:2;justify-self:start;' + BADGE + tone);
102
+ }
103
+ if (meta) meta.setAttribute('style', 'order:3;font:11px var(--mono);color:var(--muted-foreground);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap');
104
+ }
105
+ row.setAttribute('data-board-row', 'finding');
106
+ }
107
+
108
+ function findingsSection(parts) {
109
+ const [label, ...rest] = parts;
110
+ if (label) label.setAttribute('style', LBL + ';padding:0 2px');
111
+ const rows = rest.filter((n) => n.getAttribute && n.getAttribute('data-r19') === 'finding');
112
+ const notes = rest.filter((n) => !rows.includes(n));
113
+ for (const n of notes) {
114
+ const tone = /color:var\(--destructive\)/.test(n.getAttribute('style') || '') ? ';color:var(--destructive)' : '';
115
+ n.setAttribute('style', NOTE + tone + ';padding:0 2px');
116
+ }
117
+ rows.forEach(asTableRow);
118
+ const table = rows.length
119
+ ? el('div', { 'data-board-part': 'table', style: 'border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--background)' },
120
+ el('div', { 'data-board-part': 'table-head', style: COLS + ';padding:10px 14px;border-bottom:1px solid var(--border)' },
121
+ el('span', { style: TH }, 'File and finding'), el('span', { style: TH }, 'Rule'), el('span', { style: TH }, 'Severity'), el('span', { style: TH }, 'Checked by')),
122
+ rows)
123
+ : null;
124
+ return el('div', { 'data-board-section': 'findings', style: 'display:flex;flex-direction:column;gap:8px' }, label || null, notes, table);
125
+ }
126
+
127
+ function card(key, title, parts) {
128
+ if (!parts.length) return null;
129
+ return el('div', { 'data-board-section': key, style: CARD }, el('div', { style: CARD_HEAD }, title), parts);
130
+ }
131
+
132
+ export function renderBoard(inner, ctx) {
133
+ const { FIXTURE, state } = ctx;
134
+ const repo = state.product || null;
135
+
136
+ const toolbar = el('div', { 'data-board-part': 'toolbar', style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:center' });
137
+ const header = el('div', { 'data-board-part': 'header', style: 'display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap' },
138
+ el('div', null,
139
+ el('div', { 'data-board-title': '', style: 'font:600 22px var(--sans);line-height:1.45' }, 'Validate documents'),
140
+ el('div', { 'data-board-subtitle': '', style: 'color:var(--muted-foreground)' },
141
+ 'Local checks first. Nothing is sent anywhere until you consent for ' + (repo ? repo : 'this repository') + '.')),
142
+ toolbar);
143
+
144
+ const host = el('div', { 'data-board-section': 'list', style: 'display:flex;flex-direction:column;gap:14px' });
145
+ ctx.renderMode(host);
146
+ const wrap = host.querySelector('[data-r19="validate"]');
147
+
148
+ let statRow = null;
149
+ if (FIXTURE) {
150
+ statRow = el('div', { 'data-board-part': 'stats', style: NOTE }, 'Validation is not read in the fixture.');
151
+ } else if (wrap) {
152
+ statRow = stats(wrap);
153
+ // the two header actions are the list's own buttons, moved, so their hooks and presses stay as they were
154
+ const check = wrap.querySelector('[data-r22="check"]');
155
+ const plan = wrap.querySelector('[data-r19="plan"]');
156
+ if (check) { check.setAttribute('style', BTN); toolbar.appendChild(check); }
157
+ if (plan) { plan.setAttribute('style', BTNP); toolbar.appendChild(plan); }
158
+ const s = sections(wrap);
159
+ wrap.setAttribute('style', 'display:flex;flex-direction:column;gap:14px;padding:0');
160
+ const findings = s.findings.length ? findingsSection(s.findings) : null;
161
+ const foot = el('div', { 'data-board-part': 'note', style: NOTE }, 'Findings never edit a file. You decide what to fix.');
162
+ const cons = card('consistency', 'Consistency of this repository', s.consistency);
163
+ const ts = card('typesafe', 'TypeSafe (Jev)', s.typesafe);
164
+ // the consistency card keeps its own label, which the card head now says
165
+ if (s.consistency[0] && /^consistency of this repository/.test((s.consistency[0].textContent || '').trim())) s.consistency[0].style.display = 'none';
166
+ for (const n of [findings, foot, cons, ts]) if (n) wrap.appendChild(n);
167
+ }
168
+
169
+ inner.appendChild(el('div', { 'data-board': 'validate', style: 'display:flex;flex-direction:column;gap:14px;padding:4px 6px;font-size:13px;line-height:1.45;color:var(--foreground)' },
170
+ header, statRow, host));
171
+ }
@@ -20,6 +20,12 @@
20
20
  * under state/ (the reconcile seam). Request headers are never logged (D08).
21
21
  */
22
22
  'use strict';
23
+ // The workspace reads git in the background (repo-watch, git-info, claims), and a plain `git status` takes the index lock to
24
+ // refresh the index. A press that writes, such as Sync's `git add`, then fails on `index.lock: File exists` when the two
25
+ // meet. With optional locks off, the server's reads never take that lock. Set only when the PM has not set it, and taken back
26
+ // out of every terminal session's environment (sessionEnv), so the PM's own shells and agents are unchanged.
27
+ const SET_GIT_OPTIONAL_LOCKS = process.env.GIT_OPTIONAL_LOCKS === undefined;
28
+ if (SET_GIT_OPTIONAL_LOCKS) process.env.GIT_OPTIONAL_LOCKS = '0';
23
29
  const http = require('http');
24
30
  const fs = require('fs');
25
31
  const os = require('os');
@@ -65,9 +71,15 @@ const { createSinkGuard } = require('./lib/sink.js');
65
71
  const tsSettings = require('./lib/typesafe-settings.js');
66
72
  const sessionFlag = require('./lib/session-flag'); // PMOS_WORKSPACE=1 in each agent terminal (cockpit mod, M12)
67
73
  const tsClient = require('./lib/typesafe.js');
74
+ const jevRoute = require('./lib/jev-route.js'); // prompt-model-routing — the bridge between prompt routing and the one TypeSafe module
68
75
  const jevClaims = require('./lib/jev-claims.js'); // D80 — the bridge between the claims ledger and the one TypeSafe module
76
+ const jevCategorize = require('./lib/jev-categorize.js'); // D82 — the bridge between the document categorizer and the one TypeSafe module
69
77
  const validateLib = require('./lib/validate.js');
78
+ const liveNotes = require('./lib/live-notes.js'); // jev-live-notes — reads and appends a repository's live-notes ledger; no network
70
79
  const claimsLib = require('./lib/claims.js');
80
+ const docCategory = require('./lib/doc-category.js'); // jev-doc-categorizer Run 1 — the PM-facing kind of a document, derived, local, no model
81
+ const docCatCorrections = require('./lib/doc-category-corrections.js'); // jev-doc-categorizer Run 3 — the PM's own category, per user, outside the repository
82
+ const docCatWriteback = require('./lib/doc-category-writeback.js'); // …and the separate press that writes it into the file's frontmatter
71
83
  const repoWatch = require('./lib/repo-watch.js');
72
84
  tsSettings.adoptEnv(process.env); // D79 — take the env key into memory and delete it, before anything is spawned
73
85
  const scheduleLogic = require('./lib/schedule-logic');
@@ -278,6 +290,7 @@ function sessionEnv() {
278
290
  // D79 — the TypeSafe key is the workspace server's own credential. A terminal runs an agent that can
279
291
  // print its environment, so the key is never handed to one, whether it came from settings or the shell.
280
292
  delete env[tsSettings.ENV_NAME];
293
+ if (SET_GIT_OPTIONAL_LOCKS) delete env.GIT_OPTIONAL_LOCKS;
281
294
  return env;
282
295
  }
283
296
 
@@ -1536,6 +1549,85 @@ function stampEdit(text, at) {
1536
1549
  const next = fm.replace(re, `generated: {by: human:wawan, at: "${at}"}`);
1537
1550
  return next === fm ? text : text.replace(fm, () => next);
1538
1551
  }
1552
+ // ── Document category (jev-doc-categorizer Run 1) ───────────────────────────────────────────────────
1553
+ // A derived label beside the document's own `type`: local rules only, no model, nothing written. The library lists it with the
1554
+ // basis that decided it, so the PM can see why. A file is read once per modification; only its first 16 KB is needed, because
1555
+ // the evidence is the frontmatter, the name and the headings.
1556
+ const catCache = new Map();
1557
+ function categoryInput(full) {
1558
+ let st; try { st = fs.statSync(full); } catch { return null; }
1559
+ const hit = catCache.get(full);
1560
+ if (hit && hit.m === st.mtimeMs) return hit;
1561
+ let text = '';
1562
+ try { const fd = fs.openSync(full, 'r'); const buf = Buffer.alloc(16384); const n = fs.readSync(fd, buf, 0, 16384, 0); fs.closeSync(fd); text = buf.slice(0, n).toString('utf8'); } catch { return null; }
1563
+ let fm = {}; try { fm = relatedDocs.frontmatter(text) || {}; } catch { /* no frontmatter */ }
1564
+ const ent = { m: st.mtimeMs, fm, text };
1565
+ catCache.set(full, ent);
1566
+ return ent;
1567
+ }
1568
+ function namedRecords(root) {
1569
+ const records = [];
1570
+ for (const rel of ['planning/initiatives', 'pmos/state/initiatives', 'state/initiatives']) {
1571
+ const d = path.join(root, ...rel.split('/'));
1572
+ let names = []; try { names = fs.readdirSync(d).filter((f) => f.endsWith('.md')); } catch { continue; }
1573
+ for (const f of names) { const c = categoryInput(path.join(d, f)); if (c) records.push({ rel: rel + '/' + f, fm: c.fm }); }
1574
+ }
1575
+ return docCategory.namedFromRecords(records);
1576
+ }
1577
+ // Run 3 (C7) — the PM's corrections for one repository live in the per-user runtime directory, keyed like the claims cache
1578
+ // (sha1 of the root), never in the repository.
1579
+ function doccatDir(root) { return docCatCorrections.repoDir(STATE, root); }
1580
+ // Adds `category`, `category_basis` and `category_why` to every markdown document anywhere in a library answer, and
1581
+ // `category_kind` for a ticket. A stored correction is passed in, so it wins with basis `pm correction`; the bases that
1582
+ // disagree with the winner come as `category_disagrees`.
1583
+ function annotateCategories(root, node, named, corr = {}, seen = new Set()) {
1584
+ if (!node || typeof node !== 'object' || seen.has(node)) return node;
1585
+ seen.add(node);
1586
+ if (Array.isArray(node)) { for (const x of node) annotateCategories(root, x, named, corr, seen); return node; }
1587
+ if (typeof node.path === 'string' && /\.md$/i.test(node.path)) {
1588
+ const c = categoryInput(path.join(root, node.path));
1589
+ if (c) {
1590
+ const rel = node.path.split(path.sep).join('/');
1591
+ const r = docCategory.categorize({ rel, fm: c.fm, text: c.text, named, correction: Object.prototype.hasOwnProperty.call(corr, rel) ? corr[rel] : null });
1592
+ node.category = r.category; node.category_basis = r.basis; node.category_why = r.via;
1593
+ if (r.ticket_kind) node.category_kind = r.ticket_kind;
1594
+ if (r.disagrees.length) node.category_disagrees = r.disagrees;
1595
+ }
1596
+ }
1597
+ for (const k of Object.keys(node)) if (node[k] && typeof node[k] === 'object') annotateCategories(root, node[k], named, corr, seen);
1598
+ return node;
1599
+ }
1600
+ // ── D82 — Jev's category for a file the local rules leave undetermined ─────────────────────────────────
1601
+ // Its state lives beside the corrections, in doccatDir(root): per user, outside the repository.
1602
+ // Reads Jev's CACHED answers only; sends nothing. A decided answer becomes the category with the basis `jev`; an unsure one
1603
+ // leaves the file `undetermined` and says which two options led. Advisory (D40): nothing downstream reads it as fact.
1604
+ function annotateJev(root, node, cache, seen = new Set()) {
1605
+ if (!node || typeof node !== 'object' || seen.has(node)) return node;
1606
+ seen.add(node);
1607
+ if (Array.isArray(node)) { for (const x of node) annotateJev(root, x, cache, seen); return node; }
1608
+ if (node.category === docCategory.UNDETERMINED && typeof node.path === 'string' && /\.md$/i.test(node.path)) {
1609
+ const c = categoryInput(path.join(root, node.path));
1610
+ const l = c ? jevCategorize.lookup(cache, { fm: c.fm, text: c.text }) : null;
1611
+ if (l && l.decided) { node.category = l.category; node.category_basis = 'jev'; node.category_why = l.sentence; if (l.ticket_kind) node.ticket_kind = l.ticket_kind; }
1612
+ else if (l) node.category_why = l.sentence;
1613
+ }
1614
+ for (const k of Object.keys(node)) if (node[k] && typeof node[k] === 'object') annotateJev(root, node[k], cache, seen);
1615
+ return node;
1616
+ }
1617
+ // Every Markdown document the library lists, with what the categorizer needs. Read through the same per-file cache.
1618
+ function doccatDocs(root, lib) {
1619
+ const rels = new Set();
1620
+ (function walk(n, seen) {
1621
+ if (!n || typeof n !== 'object' || seen.has(n)) return; seen.add(n);
1622
+ if (Array.isArray(n)) { for (const x of n) walk(x, seen); return; }
1623
+ if (typeof n.path === 'string' && /\.md$/i.test(n.path)) rels.add(n.path.split(path.sep).join('/'));
1624
+ for (const k of Object.keys(n)) if (n[k] && typeof n[k] === 'object') walk(n[k], seen);
1625
+ })(lib, new Set());
1626
+ const docs = [];
1627
+ const corrections = docCatCorrections.get(doccatDir(root)); // C7 — a corrected file is decided, so it is never sent
1628
+ for (const rel of rels) { const c = categoryInput(path.join(root, ...rel.split('/'))); if (c) docs.push({ rel, fm: c.fm, text: c.text, correction: corrections[rel] || null }); }
1629
+ return docs;
1630
+ }
1539
1631
  function library(root) {
1540
1632
  const shape = wsShape(root);
1541
1633
  const roots = libraryTree(root);
@@ -1553,7 +1645,8 @@ function library(root) {
1553
1645
  const notes = NOTE_DIRS.map((d) => path.join(root, ...d.split('/'))).filter((d) => isDir(d))
1554
1646
  .map((d) => tree(root, d, {}, 0, { n: TREE_MAX_NODES }))
1555
1647
  .filter((t) => t.dirs.length || t.docs.length);
1556
- return { shape, roots, initiatives, ungrouped, knowledge, notes, governance: looseDocs(root) };
1648
+ const out = { shape, roots, initiatives, ungrouped, knowledge, notes, governance: looseDocs(root) };
1649
+ return annotateJev(root, annotateCategories(root, out, namedRecords(root), docCatCorrections.get(doccatDir(root))), jevCategorize.readCache(doccatDir(root)));
1557
1650
  }
1558
1651
 
1559
1652
  // ── On-demand surfaces (D60 / the workspace-ondemand-surfaces PRD). ────────────────────────────
@@ -4594,19 +4687,39 @@ async function runClaims(w, { sweep = false } = {}) {
4594
4687
  // D80 — after the local check, and only when all four gates hold (the bridge decides), ask Jev about changed claim pairs.
4595
4688
  if (out.ok) {
4596
4689
  const f = tsSettings.getRepo(STATE, w.root);
4597
- const jev = await jevClaims.run({ root: w.root, cacheDir, ledgerFile, key: tsSettings.getKey(STATE), flags: f, exclude: f.exclude, changed: out.run ? out.run.changed : null });
4690
+ const jev = await jevClaims.run({ root: w.root, cacheDir, ledgerFile, key: tsSettings.getKey(STATE), flags: f, exclude: f.exclude, changed: out.run ? out.run.changed : null, askOpts: jevModelOpts(w.root) });
4598
4691
  const fresh = claimsLib.readView({ cacheDir, ledgerFile });
4599
4692
  return { ...out, ...fresh, jev };
4600
4693
  }
4601
4694
  return out;
4602
4695
  } finally { claimsRunning.delete(w.root); }
4603
4696
  }
4697
+ // prompt-model-routing R11 — the Jev version a PM names in the model policy reaches the claim checks and keys their cache. None named: the module's own default.
4698
+ function jevModelOpts(root) { const m = jevRoute.modelFromPolicy(root); return m ? { model: m } : {}; }
4604
4699
  // D80 — what the page needs to explain the Jev switch: which gates hold, and what the last pass did.
4605
4700
  function jevGateInfo(w) {
4606
4701
  const f = tsSettings.getRepo(STATE, w.root);
4607
4702
  const g = jevClaims.gates({ key: tsSettings.status(STATE).present, consent: f.consent, watch: f.watch, jev: f.jev });
4608
4703
  const { cacheDir } = claimsPaths(w);
4609
- return { switch: f.jev, open: g.open, missing: g.missing, sentence: g.open ? null : jevClaims.gateSentence(g.missing), ...jevClaims.status(cacheDir) };
4704
+ return { switch: f.jev, status_read: f.status_read, open: g.open, missing: g.missing, sentence: g.open ? null : jevClaims.gateSentence(g.missing), ...jevClaims.status(cacheDir) };
4705
+ }
4706
+ // D82 — what the page needs to explain the categorize switch: which gates hold, and what the last pass did.
4707
+ const doccatRunning = new Set();
4708
+ const doccatAutoAt = new Map(); // root → time of the last automatic pass; at most one a minute
4709
+ function doccatInfo(w) {
4710
+ const f = tsSettings.getRepo(STATE, w.root);
4711
+ const g = jevCategorize.gates({ key: tsSettings.status(STATE).present, consent: f.consent, categorize: f.categorize });
4712
+ return { switch: f.categorize, open: g.open, missing: g.missing, sentence: g.open ? null : jevCategorize.gateSentence(g.missing), running: doccatRunning.has(w.root), ...jevCategorize.status(doccatDir(w.root)) };
4713
+ }
4714
+ // The automatic pass, after a library listing. The bridge checks the three gates and the confirmed first run; this only
4715
+ // keeps it to one pass at a time and one a minute, and never makes the listing wait for it.
4716
+ function doccatAuto(w, lib) {
4717
+ const f = tsSettings.getRepo(STATE, w.root);
4718
+ if (!f.categorize || doccatRunning.has(w.root) || Date.now() - (doccatAutoAt.get(w.root) || 0) < 60000) return;
4719
+ doccatAutoAt.set(w.root, Date.now());
4720
+ doccatRunning.add(w.root);
4721
+ jevCategorize.runAuto({ docs: doccatDocs(w.root, lib), named: namedRecords(w.root), cacheDir: doccatDir(w.root), key: tsSettings.getKey(STATE), flags: f, exclude: f.exclude })
4722
+ .catch(() => null).finally(() => doccatRunning.delete(w.root));
4610
4723
  }
4611
4724
  const claimsLast = new Map(); // root → { at, run, error }
4612
4725
  const claimsWatcher = repoWatch.createWatcher({
@@ -5072,7 +5185,9 @@ async function handleRequest(req, res) {
5072
5185
  if (p === '/api/library') {
5073
5186
  const w = getWs(url.searchParams.get('ws'));
5074
5187
  if (!w) return json(res, { ws: null, shape: 'plain', roots: [], initiatives: [], ungrouped: [], knowledge: [], governance: [] });
5075
- return json(res, { ws: w.id, ...library(w.root) });
5188
+ const lib = library(w.root);
5189
+ doccatAuto(w, lib); // D82 — in the background, and only when the three gates hold and the first run was confirmed
5190
+ return json(res, { ws: w.id, ...lib, doccat: doccatInfo(w) });
5076
5191
  }
5077
5192
  if (p === '/api/surface') {
5078
5193
  // On-demand P/D/M/R surface, derived through the local state adapter only (no network).
@@ -5427,6 +5542,36 @@ async function handleRequest(req, res) {
5427
5542
  let md; try { md = fs.readFileSync(path.join(DELIV_DIR, id + '.md'), 'utf8'); } catch { res.writeHead(404); return res.end('not found'); }
5428
5543
  res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); return res.end(md);
5429
5544
  }
5545
+ // ── prompt-model-routing — route ANY prompt to a tier. A SUGGESTION: nothing here starts, switches or types into a session. ──
5546
+ if (p === '/api/model/route/switch' && req.method === 'POST') {
5547
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
5548
+ const w = getWs(body.ws); if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
5549
+ if (typeof body.route !== 'boolean') return json(res, { ok: false, error: 'route must be true or false' }, 400);
5550
+ const out = tsSettings.setRepo(STATE, w.root, { route: body.route });
5551
+ if (!out.ok) return json(res, { ok: false, error: out.error }, 500);
5552
+ const f = tsSettings.getRepo(STATE, w.root);
5553
+ const g = jevRoute.gates({ key: tsSettings.status(STATE).present, consent: f.consent, route: f.route });
5554
+ return json(res, { ok: true, ws: w.id, route: f.route, open: g.open, missing: g.missing, sentence: g.open ? null : jevRoute.gateSentence(g.missing) });
5555
+ }
5556
+ if (p === '/api/model/route' && req.method === 'POST') {
5557
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
5558
+ const w = getWs(body.ws); if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
5559
+ if (typeof body.prompt !== 'string' || !body.prompt.trim() || body.prompt.length > 60000) return json(res, { ok: false, error: 'prompt must be text of up to 60000 characters' }, 400);
5560
+ const f = tsSettings.getRepo(STATE, w.root);
5561
+ const out = await jevRoute.routePrompt({ root: w.root, prompt: body.prompt, ctx: { skill: typeof body.skill === 'string' ? body.skill : undefined, source: body.source, running: true },
5562
+ key: tsSettings.getKey(STATE), flags: { consent: f.consent, route: f.route, exclude: f.exclude } });
5563
+ if (!out.ok) return json(res, { ok: false, error: out.error }, 400);
5564
+ if (out.shadow) return json(res, { ok: true, shadow: true, id: out.id }); // shadow: logged, nothing shown, nothing applied
5565
+ return json(res, { ok: true, ws: w.id, id: out.id, tier: out.tier, model: out.model, effort: out.effort, stage: out.stage, confidence: out.confidence, why: out.why,
5566
+ applies: out.applies, sent: out.sent, withheld: out.withheld ? out.withheld.why : null, gate: out.gate });
5567
+ }
5568
+ if (p === '/api/model/route/outcome' && req.method === 'POST') {
5569
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
5570
+ const w = getWs(body.ws); if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
5571
+ const mc = modelLib.core(); if (!mc.ok) return json(res, { ok: false, error: mc.error }, 500);
5572
+ if (!mc.core.attachOutcome(w.root, body.id, { outcome: body.outcome, applied_tier: body.tier })) return json(res, { ok: false, error: 'an id from a routed prompt and an outcome of ' + mc.core.OUTCOMES.join(', ') + ' are needed (and the decisions ledger opted in)' }, 400);
5573
+ return json(res, { ok: true });
5574
+ }
5430
5575
  if (p === '/api/session/model' && req.method === 'POST') {
5431
5576
  // D75 — switch the model of a RUNNING Claude session, on the PM's press only. It types the harness's own
5432
5577
  // `/model <model>` command into the pane; effort is not changed by it. Never automatic, never for a binary
@@ -5740,7 +5885,12 @@ async function handleRequest(req, res) {
5740
5885
  try { nextStepActions.annotate(nextSteps, (it) => nextStepPathOf(root, it)); attachRuns(nextSteps, id); } catch (e) { void e; }
5741
5886
  let lcHistory = [];
5742
5887
  try { lcHistory = lifecycleHistory(w, id); } catch { lcHistory = []; }
5743
- return json(res, { ws: w ? w.id : null, ...out, lanes, conversations, files, next_steps: nextSteps, lifecycle_history: lcHistory });
5888
+ // D83 — what Jev suggests about the documents tied to this initiative, read from the claims ledger. Advisory: the
5889
+ // panel shows it beside the ladder and nothing derives a step, a stage or a count from it.
5890
+ let suggestions = null;
5891
+ try { if (w) suggestions = jevClaims.suggestionsFor({ ledgerFile: claimsPaths(w).ledgerFile, initiativeId: id }); }
5892
+ catch { suggestions = { ok: false, reason: 'the suggestions could not be read', items: [] }; }
5893
+ return json(res, { ws: w ? w.id : null, ...out, lanes, conversations, files, next_steps: nextSteps, lifecycle_history: lcHistory, suggestions });
5744
5894
  }
5745
5895
  if (p === '/api/audit' && req.method === 'GET') {
5746
5896
  /* The workflow audit (D77): the sweep run on one repository, read-only. It reports state that contradicts
@@ -6231,6 +6381,62 @@ async function handleRequest(req, res) {
6231
6381
  if (!out.ok) return json(res, { ok: false, error: out.error }, 500);
6232
6382
  return json(res, { ok: true, ws: w.id, watching: tsSettings.getRepo(STATE, w.root).watch === true });
6233
6383
  }
6384
+ // ── jev-doc-categorizer Run 3 (C7) — the PM corrects a category, and may write it into the file ─────────────────────
6385
+ // A correction is per-user state OUTSIDE the repository and never edits a file. The write-back is a separate press with
6386
+ // two steps — the diff first, then a confirmed write that names the sha1 of the text the PM saw — and it is the ONLY
6387
+ // write into the repository on this surface.
6388
+ if (p === '/api/doccat/corrections' && req.method === 'GET') {
6389
+ const w = getWs(url.searchParams.get('ws'));
6390
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6391
+ return json(res, { ok: true, ws: w.id, corrections: docCatCorrections.get(doccatDir(w.root)) });
6392
+ }
6393
+ if ((p === '/api/doccat/correct' || p === '/api/doccat/writeback') && req.method === 'POST') {
6394
+ let body = {};
6395
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6396
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6397
+ const w = getWs(body.ws);
6398
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6399
+ // Path guard: the store's own check (relative, no `..`, Markdown only), then the same inside-the-workspace rule every
6400
+ // read and write on this surface uses, then the real path, so a symlink cannot lead out of the repository.
6401
+ const chk = docCatCorrections.checkRel(body.path);
6402
+ if (chk.error) return json(res, { ok: false, error: chk.error }, 400);
6403
+ const rel = chk.rel;
6404
+ const full = path.resolve(w.root, ...rel.split('/'));
6405
+ if (!insideRoot(w.root, full)) return json(res, { ok: false, error: 'outside workspace' }, 404);
6406
+ let lst = null; try { lst = fs.lstatSync(full); } catch { /* reported below */ }
6407
+ if (!lst) return json(res, { ok: false, error: 'There is no such document in this repository.' }, 404);
6408
+ let real = null; try { real = fs.realpathSync(full); } catch { /* reported below */ }
6409
+ let realRoot = w.root; try { realRoot = fs.realpathSync(w.root); } catch { /* keep the root as given */ }
6410
+ if (!real || !insideRoot(realRoot, real)) return json(res, { ok: false, error: 'outside workspace' }, 404);
6411
+ const dir = doccatDir(w.root);
6412
+ if (p === '/api/doccat/correct') {
6413
+ const out = body.clear === true
6414
+ ? docCatCorrections.clear(dir, rel)
6415
+ : docCatCorrections.set(dir, rel, { category: body.category, ticket_kind: body.ticket_kind === undefined ? null : body.ticket_kind });
6416
+ if (!out.ok) return json(res, { ok: false, error: out.error }, /could not be saved|symlink/.test(out.error) ? 500 : 400);
6417
+ return json(res, { ok: true, ws: w.id, path: rel, cleared: body.clear === true ? out.cleared : undefined, correction: out.correction || null, corrections: docCatCorrections.get(dir) });
6418
+ }
6419
+ // the write-back
6420
+ if (lst.isSymbolicLink() || !lst.isFile()) return json(res, { ok: false, error: 'This document is a link or not a plain file, so the category was not written into it.' }, 400);
6421
+ let current;
6422
+ try { current = fs.readFileSync(full, 'utf8'); }
6423
+ catch { return json(res, { ok: false, error: 'could not read the document' }, 404); }
6424
+ const corr = { category: body.category, ticket_kind: body.ticket_kind === undefined ? null : body.ticket_kind };
6425
+ if (body.confirm !== true) {
6426
+ const plan = docCatWriteback.plan(current, corr);
6427
+ if (!plan.ok) return json(res, { ok: false, error: plan.error, sha: plan.sha }, 400);
6428
+ return json(res, { ok: true, ws: w.id, path: rel, written: false, diff: plan.diff, changes: plan.changes, sha: plan.sha, next_sha: plan.next_sha, unchanged: plan.unchanged });
6429
+ }
6430
+ const done = docCatWriteback.apply(current, corr, body.expect_sha, body.expect_next_sha);
6431
+ if (!done.ok) return json(res, { ok: false, error: done.error, stale: !!done.stale, sha: done.sha }, done.stale ? 409 : 400);
6432
+ if (done.unchanged) return json(res, { ok: true, ws: w.id, path: rel, written: false, unchanged: true, diff: '' });
6433
+ try { fs.writeFileSync(full, done.next); }
6434
+ catch (e) {
6435
+ warnWriteFail('category write-back (' + rel + ')', e);
6436
+ return json(res, { ok: false, error: 'could not write the document — nothing was changed' }, 500);
6437
+ }
6438
+ return json(res, { ok: true, ws: w.id, path: rel, written: true, diff: done.diff, changes: done.changes, sha: docCatWriteback.sha1(done.next) });
6439
+ }
6234
6440
  // ── D80 — Jev on changed claim pairs: the switch, a resume after a pause, and the backlog (plan, then confirmed run) ──
6235
6441
  if (p === '/api/claims/jev' && req.method === 'POST') {
6236
6442
  let body = {};
@@ -6243,6 +6449,18 @@ async function handleRequest(req, res) {
6243
6449
  if (!out.ok) return json(res, { ok: false, error: out.error }, 500);
6244
6450
  return json(res, { ok: true, ws: w.id, jev: jevGateInfo(w) });
6245
6451
  }
6452
+ // D83 — the second switch: also read the documents tied to each initiative for the two status questions. Default off.
6453
+ if (p === '/api/claims/jev/status' && req.method === 'POST') {
6454
+ let body = {};
6455
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6456
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6457
+ const w = getWs(body.ws);
6458
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6459
+ if (typeof body.status_read !== 'boolean') return json(res, { ok: false, error: 'status_read must be true or false' }, 400);
6460
+ const out = tsSettings.setRepo(STATE, w.root, { status_read: body.status_read });
6461
+ if (!out.ok) return json(res, { ok: false, error: out.error }, 500);
6462
+ return json(res, { ok: true, ws: w.id, jev: jevGateInfo(w) });
6463
+ }
6246
6464
  if (p === '/api/claims/jev/resume' && req.method === 'POST') {
6247
6465
  let body = {};
6248
6466
  try { body = JSON.parse((await readBody(req)) || '{}'); }
@@ -6262,14 +6480,61 @@ async function handleRequest(req, res) {
6262
6480
  const g = jevClaims.gates({ key: tsSettings.status(STATE).present, consent: f.consent, watch: true, jev: f.jev });
6263
6481
  if (!g.open) return json(res, { ok: false, error: jevClaims.gateSentence(g.missing) }, 409);
6264
6482
  const { cacheDir, ledgerFile } = claimsPaths(w);
6265
- if (body.confirm !== true) return json(res, { ok: true, ws: w.id, plan: jevClaims.backlogPlan({ root: w.root, cacheDir, exclude: f.exclude }) });
6483
+ if (body.confirm !== true) return json(res, { ok: true, ws: w.id, plan: jevClaims.backlogPlan({ root: w.root, cacheDir, exclude: f.exclude, model: jevRoute.modelFromPolicy(w.root) || undefined, flags: f }) });
6266
6484
  if (claimsRunning.has(w.root)) return json(res, { ok: false, busy: true, error: 'a check is already running for this repository' }, 409);
6267
6485
  claimsRunning.add(w.root);
6268
6486
  try {
6269
- const out = await jevClaims.runBacklog({ root: w.root, cacheDir, ledgerFile, key: tsSettings.getKey(STATE), flags: f, exclude: f.exclude, expectRequests: body.expect_requests });
6487
+ const out = await jevClaims.runBacklog({ root: w.root, cacheDir, ledgerFile, key: tsSettings.getKey(STATE), flags: f, exclude: f.exclude, expectRequests: body.expect_requests, askOpts: jevModelOpts(w.root) });
6270
6488
  return json(res, { ws: w.id, ...out, jev: jevGateInfo(w), ...claimsLib.readView({ cacheDir, ledgerFile }) }, out.ok ? 200 : (out.conflict ? 409 : 500));
6271
6489
  } finally { claimsRunning.delete(w.root); }
6272
6490
  }
6491
+ // ── D82 — Jev on documents the local rules leave undetermined: the switch, a resume, and the run (plan, then confirmed) ──
6492
+ if (p === '/api/doccat/jev' && req.method === 'GET') {
6493
+ const w = getWs(url.searchParams.get('ws'));
6494
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6495
+ return json(res, { ok: true, ws: w.id, doccat: doccatInfo(w) });
6496
+ }
6497
+ if (p === '/api/doccat/jev' && req.method === 'POST') {
6498
+ let body = {};
6499
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6500
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6501
+ const w = getWs(body.ws);
6502
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6503
+ if (typeof body.categorize !== 'boolean') return json(res, { ok: false, error: 'categorize must be true or false' }, 400);
6504
+ const out = tsSettings.setRepo(STATE, w.root, { categorize: body.categorize });
6505
+ if (!out.ok) return json(res, { ok: false, error: out.error }, 500);
6506
+ return json(res, { ok: true, ws: w.id, doccat: doccatInfo(w) });
6507
+ }
6508
+ if (p === '/api/doccat/jev/resume' && req.method === 'POST') {
6509
+ let body = {};
6510
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6511
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6512
+ const w = getWs(body.ws);
6513
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6514
+ jevCategorize.resume(doccatDir(w.root));
6515
+ return json(res, { ok: true, ws: w.id, doccat: doccatInfo(w) });
6516
+ }
6517
+ if (p === '/api/doccat/jev/run' && req.method === 'POST') {
6518
+ let body = {};
6519
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6520
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6521
+ const w = getWs(body.ws);
6522
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6523
+ const f = tsSettings.getRepo(STATE, w.root);
6524
+ const g = jevCategorize.gates({ key: tsSettings.status(STATE).present, consent: f.consent, categorize: f.categorize });
6525
+ if (!g.open) return json(res, { ok: false, error: jevCategorize.gateSentence(g.missing), doccat: doccatInfo(w) }, 409);
6526
+ const cacheDir = doccatDir(w.root);
6527
+ const docs = doccatDocs(w.root, library(w.root));
6528
+ const named = namedRecords(w.root);
6529
+ if (body.confirm !== true) return json(res, { ok: true, ws: w.id, plan: jevCategorize.plan({ docs, named, cacheDir, exclude: f.exclude }) });
6530
+ if (doccatRunning.has(w.root)) return json(res, { ok: false, busy: true, error: 'Jev is already categorizing this repository' }, 409);
6531
+ doccatRunning.add(w.root);
6532
+ try {
6533
+ const out = await jevCategorize.runConfirmed({ docs, named, cacheDir, key: tsSettings.getKey(STATE), flags: f, exclude: f.exclude, expectRequests: body.expect_requests,
6534
+ expectFingerprint: typeof body.expect_fingerprint === 'string' ? body.expect_fingerprint : null });
6535
+ return json(res, { ws: w.id, ...out, doccat: doccatInfo(w) }, out.ok ? 200 : (out.conflict || out.busy ? 409 : 500));
6536
+ } finally { doccatRunning.delete(w.root); }
6537
+ }
6273
6538
  // ── D79 — repository validation through TypeSafe ──────────────────────────────────────────────────
6274
6539
  // The ONLY routes that lead to an outbound request, and only through lib/typesafe.js. The key is never
6275
6540
  // in a response: `status` says present or absent. Nothing here edits a repository file.
@@ -6300,6 +6565,24 @@ async function handleRequest(req, res) {
6300
6565
  }
6301
6566
  return json(res, { ok: true, ws: w.id, name: w.name, ...tsSettings.getRepo(STATE, w.root), key: tsSettings.status(STATE) });
6302
6567
  }
6568
+ /* ── live notes (jev-live-notes, Run 2): the ranked proposals of a conversation, and the PM's accept, change or reject.
6569
+ * Reads the repository's ledger; the one write is a decision row. No request leaves this process, and no page is built yet. ── */
6570
+ if (p === '/api/live-notes' && req.method === 'GET') {
6571
+ const wsq = url.searchParams.get('ws');
6572
+ const w = wsq ? workspaces.get(wsq) : homeWorkspace(); // a named workspace the server does not know is refused, never replaced by the home one
6573
+ if (!w) return json(res, { ok: false, error: 'no such workspace' }, 400);
6574
+ const r = liveNotes.apiList({ root: w.root, session: url.searchParams.get('session') || null });
6575
+ return json(res, r, r.ok ? 200 : (r.status || 400));
6576
+ }
6577
+ if (p === '/api/live-notes/decide' && req.method === 'POST') {
6578
+ let body = {};
6579
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6580
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6581
+ const w = body.ws ? workspaces.get(String(body.ws)) : homeWorkspace();
6582
+ if (!w) return json(res, { ok: false, error: 'no such workspace' }, 400);
6583
+ const r = liveNotes.apiDecide({ root: w.root, runtimeDir: STATE, session: body.session, segment: body.segment, action: body.action, initiative: body.initiative || null, relation: body.relation || null, exclude: tsSettings.getRepo(STATE, w.root).exclude });
6584
+ return json(res, r, r.ok ? 200 : (r.status || 400));
6585
+ }
6303
6586
  if (p === '/api/validate/plan' && req.method === 'POST') {
6304
6587
  let body = {};
6305
6588
  try { body = JSON.parse((await readBody(req)) || '{}'); }