create-pmos 0.40.0 → 0.42.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.
@@ -0,0 +1,266 @@
1
+ 'use strict';
2
+ /* kg.js — the repository's knowledge graph, derived from what its files already state (knowledge-graph K1).
3
+ *
4
+ * Pure: takes [{path, text}] for the tracked Markdown files and returns nodes, edges and the dangling links.
5
+ * No filesystem, no git, no network, no model. Same input → byte-identical output.
6
+ *
7
+ * WHAT IS A NODE. One tracked .md file. It carries a CARD (the frontmatter `description`, else the first paragraph,
8
+ * cut at 240 characters), a token estimate (characters ÷ 4 — an estimate, not a measurement), and its headings with
9
+ * line ranges so a caller can read ONE section and not the file.
10
+ *
11
+ * WHAT IS AN EDGE. Only a relation a file writes down:
12
+ * link a Markdown link to a tracked file basis stated
13
+ * frontmatter a path in prd_path / rubric_path / explainer_path / sources basis stated
14
+ * supersedes · amended_by · cites (typed keys a document opts into) basis stated
15
+ * initiative initiative_id (frontmatter or an identity bullet) → its record basis stated
16
+ * anchor parent_kr / parent_KR `health:<id>` or a KR id → the file naming it basis stated
17
+ * cites a `D<nn>` mention in prose → that decision's ADR view basis mention
18
+ * A link to a file that does not exist is a DANGLING item, never an edge. A link-like string inside a code fence or
19
+ * an inline code span is not a relation and makes no edge. Nothing is inferred by similarity; workspace/lib/
20
+ * related-docs.js took the same position for alignment, for the same reason.
21
+ *
22
+ * `D<nn>` needs two or more digits on purpose: slice labels such as "D1" and "D3" in the proactive-engine documents
23
+ * would otherwise cite decisions D01 and D03 that they never mention.
24
+ */
25
+ (function (root, factory) {
26
+ const api = factory();
27
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
28
+ else root.Kg = api;
29
+ })(typeof self !== 'undefined' ? self : this, function () {
30
+
31
+ const CARD_MAX = 240;
32
+ const ADR_DIR = 'okf/products/pmos/adr/';
33
+ const PATH_KEYS = ['prd_path', 'rubric_path', 'explainer_path', 'sources'];
34
+ const TYPED_KEYS = ['supersedes', 'amended_by', 'cites'];
35
+ const estTokens = (s) => Math.ceil(String(s || '').length / 4);
36
+
37
+ /* Replace fenced code blocks and inline code spans with spaces of the same length, keeping every newline, so line
38
+ * numbers stay true and nothing inside code is read as a link, a citation or a heading. */
39
+ function stripCode(text) {
40
+ const blank = (s) => s.replace(/[^\n]/g, ' ');
41
+ let out = '';
42
+ let fence = null;
43
+ for (const line of String(text || '').split('\n')) {
44
+ const m = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
45
+ if (fence) { out += blank(line) + '\n'; if (m && m[1][0] === fence[0] && m[1].length >= fence.length) fence = null; continue; }
46
+ if (m) { fence = m[1]; out += blank(line) + '\n'; continue; }
47
+ out += line.replace(/(`+)[^`\n]*?\1/g, (s) => blank(s)) + '\n';
48
+ }
49
+ return out.slice(0, -1);
50
+ }
51
+
52
+ /* Frontmatter values come from the reader workspace/lib/related-docs.js already ships (one-line `key: value`);
53
+ * the body start line is the only thing computed here. */
54
+ const readFm = (typeof require === 'function') ? require('./related-docs').frontmatter : null;
55
+ function frontmatter(text) {
56
+ const m = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(String(text || ''));
57
+ if (!m) return { fm: {}, bodyStart: 1 };
58
+ return { fm: readFm ? readFm(text) : {}, bodyStart: m[0].split('\n').length };
59
+ }
60
+
61
+ function headingsOf(stripped, bodyStart) {
62
+ const lines = stripped.split('\n');
63
+ const found = [];
64
+ for (let i = bodyStart - 1; i < lines.length; i++) {
65
+ const m = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(lines[i]);
66
+ if (m) found.push({ level: m[1].length, text: m[2].trim(), start: i + 1 });
67
+ }
68
+ for (let k = 0; k < found.length; k++) {
69
+ let end = lines.length;
70
+ for (let j = k + 1; j < found.length; j++) if (found[j].level <= found[k].level) { end = found[j].start - 1; break; }
71
+ found[k].end = end;
72
+ }
73
+ return found;
74
+ }
75
+
76
+ function cardOf(fm, stripped, bodyStart) {
77
+ if (fm.description) return fm.description.slice(0, CARD_MAX);
78
+ const lines = stripped.split('\n').slice(bodyStart - 1);
79
+ const para = [];
80
+ for (const raw of lines) {
81
+ const l = raw.trim();
82
+ if (!l) { if (para.length) break; continue; }
83
+ if (/^(#|\||>|---|[-*]\s|\d+\.\s|<)/.test(l)) { if (para.length) break; continue; }
84
+ para.push(l);
85
+ }
86
+ return para.join(' ').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').slice(0, CARD_MAX);
87
+ }
88
+
89
+ const norm = (from, target) => {
90
+ const t = target.replace(/^<|>$/g, '');
91
+ const parts = (t.startsWith('/') ? t.slice(1) : from.replace(/[^/]*$/, '') + t).split('/');
92
+ const out = [];
93
+ for (const p of parts) { if (p === '.' || p === '') continue; if (p === '..') out.pop(); else out.push(p); }
94
+ return out.join('/');
95
+ };
96
+
97
+ const pathsIn = (value) => [...String(value || '').matchAll(/[\w@./-]+\.md\b/g)].map((m) => m[0].replace(/^\//, ''));
98
+
99
+ function build(files) {
100
+ const list = (files || []).filter((f) => f && typeof f.path === 'string').sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
101
+ const have = new Set(list.map((f) => f.path));
102
+ const adrFor = new Map();
103
+ for (const p of have) { const m = /^okf\/products\/pmos\/adr\/d(\d{2,3})-/.exec(p); if (m) adrFor.set('D' + m[1], p); }
104
+ const krFile = new Map();
105
+ for (const f of list) if (f.path.startsWith('planning/okrs/')) for (const m of f.text.matchAll(/\bO\d+\.KR\d+\b/g)) if (!krFile.has(m[0])) krFile.set(m[0], f.path);
106
+ const budgets = have.has('planning/okrs/health-budgets.md') ? 'planning/okrs/health-budgets.md' : null;
107
+
108
+ const nodes = [];
109
+ const edges = new Map();
110
+ const dangling = [];
111
+ const meta = { decisionLines: {} };
112
+ const addEdge = (from, to, type, basis) => {
113
+ if (from === to || !have.has(to)) return;
114
+ const key = from + '\u0000' + to + '\u0000' + type;
115
+ const cur = edges.get(key);
116
+ if (!cur) edges.set(key, { from, to, type, basis });
117
+ else if (cur.basis === 'mention' && basis === 'stated') cur.basis = 'stated';
118
+ };
119
+
120
+ for (const f of list) {
121
+ const text = String(f.text || '');
122
+ const { fm, bodyStart } = frontmatter(text);
123
+ const stripped = stripCode(text);
124
+ const body = stripped;
125
+ const heads = headingsOf(stripped, bodyStart);
126
+ const title = fm.title || (heads.find((h) => h.level === 1) || {}).text || f.path;
127
+ nodes.push({
128
+ id: f.path, title, type: fm.type || null, card: cardOf(fm, stripped, bodyStart),
129
+ described: !!fm.description, tokens_est: estTokens(text), lines: text.split('\n').length,
130
+ headings: heads.map((h) => ({ level: h.level, text: h.text, start: h.start, end: h.end })),
131
+ status: fm.status || null, stale_after: fm.stale_after || null,
132
+ });
133
+
134
+ for (const m of body.matchAll(/\]\(([^)\s]+?)(?:#[^)]*)?\)/g)) {
135
+ const raw = m[1];
136
+ if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('#')) continue;
137
+ if (!/\.md$/i.test(raw)) continue;
138
+ const to = norm(f.path, raw);
139
+ if (have.has(to)) addEdge(f.path, to, 'link', 'stated');
140
+ else dangling.push({ from: f.path, to: raw });
141
+ }
142
+ for (const m of body.matchAll(/\bD(\d{2,3})\b/g)) {
143
+ const to = adrFor.get('D' + m[1]);
144
+ if (to) addEdge(f.path, to, 'cites', 'mention');
145
+ }
146
+ for (const k of PATH_KEYS) for (const p of pathsIn(fm[k])) addEdge(f.path, p, 'frontmatter', 'stated');
147
+ for (const k of TYPED_KEYS) for (const p of pathsIn(fm[k])) addEdge(f.path, p, k, 'stated');
148
+ const ini = fm.initiative_id || ((/^[-*]?\s*initiative_id:\s*([\w.-]+)/m.exec(body) || [])[1]);
149
+ if (ini) addEdge(f.path, 'planning/initiatives/' + ini + '.md', 'initiative', 'stated');
150
+ const anchor = fm.parent_kr || fm.parent_KR || ((/^[-*]?\s*parent_kr:\s*([\w:.-]+)/im.exec(body) || [])[1]);
151
+ if (anchor) {
152
+ if (/^health:/.test(anchor) && budgets) addEdge(f.path, budgets, 'anchor', 'stated');
153
+ else if (krFile.has(anchor)) addEdge(f.path, krFile.get(anchor), 'anchor', 'stated');
154
+ }
155
+ if (f.path === 'DECISIONS.md') {
156
+ const lines = body.split('\n');
157
+ for (let i = 0; i < lines.length; i++) { const m = /\bAdded \*\*(D\d{2,3})\*\*/.exec(lines[i]); if (m && !(m[1] in meta.decisionLines)) meta.decisionLines[m[1]] = i + 1; }
158
+ }
159
+ }
160
+ const e = [...edges.values()].sort((a, b) => (a.from + a.to + a.type < b.from + b.to + b.type ? -1 : 1));
161
+ dangling.sort((a, b) => (a.from + a.to < b.from + b.to ? -1 : a.from + a.to > b.from + b.to ? 1 : 0));
162
+ return { nodes, edges: e, dangling, meta };
163
+ }
164
+
165
+ /* Split dangling links into "history by design" and "needs a decision" by a readable rule list:
166
+ * [{from?: regex, to?: regex, why: string}] — a rule matches when every field it names matches. */
167
+ function splitDangling(graph, rules) {
168
+ const compiled = (rules || []).map((r) => ({ from: r.from ? new RegExp(r.from) : null, to: r.to ? new RegExp(r.to) : null, why: r.why || '' }));
169
+ const history = []; const needs = [];
170
+ for (const d of graph.dangling) {
171
+ const r = compiled.find((x) => (x.from || x.to) && (!x.from || x.from.test(d.from)) && (!x.to || x.to.test(d.to)));
172
+ if (r) history.push({ ...d, why: r.why }); else needs.push(d);
173
+ }
174
+ return { history, needs };
175
+ }
176
+
177
+ function stats(graph, rules) {
178
+ const byType = {}; const byBasis = { stated: 0, mention: 0 };
179
+ for (const e of graph.edges) { byType[e.type] = (byType[e.type] || 0) + 1; byBasis[e.basis]++; }
180
+ const inbound = new Set(graph.edges.map((e) => e.to));
181
+ const orphans = graph.nodes.filter((n) => !inbound.has(n.id) && !/(^|\/)(index|log)\.md$/.test(n.id)).length;
182
+ const split = splitDangling(graph, rules);
183
+ return {
184
+ nodes: graph.nodes.length, edges: graph.edges.length, byType, byBasis,
185
+ cardsWritten: graph.nodes.filter((n) => n.described).length,
186
+ cardsDerived: graph.nodes.filter((n) => !n.described && n.card).length,
187
+ noCard: graph.nodes.filter((n) => !n.card).length,
188
+ noType: graph.nodes.filter((n) => !n.type).length,
189
+ noIncoming: orphans, dangling: graph.dangling.length,
190
+ danglingHistory: split.history.length, danglingNeedsDecision: split.needs.length, needsDecision: split.needs,
191
+ };
192
+ }
193
+
194
+ const nodeById = (graph, id) => graph.nodes.find((n) => n.id === id) || null;
195
+ const cardText = (n) => `${n.id} [${n.type || 'untyped'}] ${n.title}${n.card ? ' — ' + n.card : ''} (file ~${n.tokens_est} tokens)`;
196
+
197
+ /* Rank: where the term matches (id 3, title 3, heading 2, card 1), then how many files link to the node. */
198
+ function find(graph, term, { limit = 20 } = {}) {
199
+ const t = String(term || '').toLowerCase().trim();
200
+ if (!t) return [];
201
+ const indeg = new Map();
202
+ for (const e of graph.edges) indeg.set(e.to, (indeg.get(e.to) || 0) + 1);
203
+ const out = [];
204
+ for (const n of graph.nodes) {
205
+ let score = 0; const where = [];
206
+ if (n.id.toLowerCase().includes(t)) { score += 3; where.push('id'); }
207
+ if (n.title.toLowerCase().includes(t)) { score += 3; where.push('title'); }
208
+ const h = n.headings.find((x) => x.text.toLowerCase().includes(t));
209
+ if (h) { score += 2; where.push('heading "' + h.text + '"'); }
210
+ if ((n.card || '').toLowerCase().includes(t)) { score += 1; where.push('card'); }
211
+ if (score) out.push({ node: n, score, where, indegree: indeg.get(n.id) || 0 });
212
+ }
213
+ return out.sort((a, b) => b.score - a.score || b.indegree - a.indegree || (a.node.id < b.node.id ? -1 : 1)).slice(0, limit);
214
+ }
215
+
216
+ function neighbors(graph, id, { type = null, depth = 1 } = {}) {
217
+ if (!nodeById(graph, id)) return null;
218
+ const seen = new Map([[id, 0]]); const rows = [];
219
+ let frontier = [id];
220
+ for (let d = 1; d <= depth; d++) {
221
+ const next = [];
222
+ for (const e of graph.edges) {
223
+ if (type && e.type !== type) continue;
224
+ for (const [a, b, dir] of [[e.from, e.to, 'out'], [e.to, e.from, 'in']]) {
225
+ if (!frontier.includes(a) || seen.has(b)) continue;
226
+ seen.set(b, d); next.push(b); rows.push({ id: b, via: e.type, basis: e.basis, direction: dir, depth: d, from: a });
227
+ }
228
+ }
229
+ frontier = next;
230
+ }
231
+ return rows;
232
+ }
233
+
234
+ /* why("D65"): the ADR view, the amendment-log line in DECISIONS.md, and every file that cites the ADR, by kind. */
235
+ function why(graph, dId) {
236
+ const id = String(dId || '').toUpperCase().replace(/^D(\d)$/, 'D0$1');
237
+ const adr = graph.nodes.find((n) => n.id.startsWith(ADR_DIR + id.toLowerCase() + '-'));
238
+ if (!adr) return null;
239
+ const groups = {};
240
+ const byFrom = new Map();
241
+ for (const e of graph.edges) if (e.to === adr.id) {
242
+ const cur = byFrom.get(e.from) || { id: e.from, via: [], basis: 'mention' };
243
+ if (!cur.via.includes(e.type)) cur.via.push(e.type);
244
+ if (e.basis === 'stated') cur.basis = 'stated';
245
+ byFrom.set(e.from, cur);
246
+ }
247
+ for (const r of byFrom.values()) { const n = nodeById(graph, r.id); const k = (n && n.type) || 'untyped'; r.via.sort(); (groups[k] = groups[k] || []).push(r); }
248
+ for (const k of Object.keys(groups)) groups[k].sort((a, b) => (a.id < b.id ? -1 : 1));
249
+ const line = graph.meta && graph.meta.decisionLines ? graph.meta.decisionLines[id] || null : null;
250
+ return { decision: id, adr, amendmentLog: line ? { path: 'DECISIONS.md', line } : null, citedBy: groups };
251
+ }
252
+
253
+ /* section(node, text, spec): spec is "#heading words" or "from-to". Returns {from,to,text} or null. */
254
+ function section(node, text, spec) {
255
+ const lines = String(text || '').split('\n');
256
+ const m = /^(\d+)-(\d+)$/.exec(spec || '');
257
+ if (m) { const from = Math.max(1, +m[1]); const to = Math.min(lines.length, +m[2]); if (from > to) return null; return { from, to, text: lines.slice(from - 1, to).join('\n') }; }
258
+ const want = String(spec || '').replace(/^#/, '').trim().toLowerCase();
259
+ if (!want || !node) return null;
260
+ const h = node.headings.find((x) => x.text.toLowerCase() === want) || node.headings.find((x) => x.text.toLowerCase().includes(want));
261
+ if (!h) return null;
262
+ return { from: h.start, to: h.end, heading: h.text, text: lines.slice(h.start - 1, h.end).join('\n') };
263
+ }
264
+
265
+ return { build, stats, splitDangling, find, neighbors, why, section, nodeById, cardText, stripCode, frontmatter, estTokens, ADR_DIR };
266
+ });
@@ -94,16 +94,19 @@ function getRepo(stateDir, root) {
94
94
  // claims-ledger: keep this repository checked as its files change. Default OFF — switching it on makes the
95
95
  // workspace append to the repository's claims.jsonl, so it is the PM's press and never a surprise.
96
96
  watch: !!(r && r.watch === true),
97
+ // jev-claim-checks (D80): also ask Jev about changed claim pairs after each check. Default OFF, separate from
98
+ // `consent` and `watch`; a request needs all of key, consent, watch and this.
99
+ jev: !!(r && r.jev === true),
97
100
  };
98
101
  }
99
102
 
100
- function setRepo(stateDir, root, { consent, exclude, watch }) {
103
+ function setRepo(stateDir, root, { consent, exclude, watch, jev }) {
101
104
  const cur = read(stateDir);
102
105
  const prev = getRepo(stateDir, root);
103
106
  const ex = Array.isArray(exclude)
104
107
  ? exclude.map((x) => String(x).trim().replace(/^\/+/, '')).filter((x) => x && !x.includes('..') && x.length <= 200).slice(0, MAX_EXCLUDES)
105
108
  : prev.exclude;
106
- const repos = { ...cur.repos, [root]: { consent: typeof consent === 'boolean' ? consent : prev.consent, exclude: ex, watch: typeof watch === 'boolean' ? watch : prev.watch } };
109
+ const repos = { ...cur.repos, [root]: { consent: typeof consent === 'boolean' ? consent : prev.consent, exclude: ex, watch: typeof watch === 'boolean' ? watch : prev.watch, jev: typeof jev === 'boolean' ? jev : prev.jev } };
107
110
  return write(stateDir, { key: cur.key, repos });
108
111
  }
109
112
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.40.0",
3
+ "version": "0.42.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.40.0",
3
+ "version": "0.42.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -10,12 +10,12 @@
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
12
  "test": "node test.js && npm run test:modules",
13
- "test:modules": "node proactive.test.js && node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node typesafe-validation.test.js && node claims.test.js && node repo-watch.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js && node guide.test.js && node next-step-actions.test.js",
13
+ "test:modules": "node proactive.test.js && node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.test.js && node story-artifacts.test.js && node no-network.test.js && node typesafe-validation.test.js && node claims.test.js && node jev-claims.test.js && node repo-watch.test.js && node fixture-parity.test.mjs && node markdown.test.mjs && node start-policy.test.js && node state-isolation.test.js && node sync.test.js && node schedule-cli.test.js && node initiative-surface.test.js && node guide.test.js && node next-step-actions.test.js && node kg.test.js",
14
14
  "test:ui": "node ui-test.js",
15
15
  "test:parity": "node design-parity.js --states",
16
16
  "test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
17
17
  "doctor": "node doctor.js",
18
- "test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js && node pm-r16-test.js && node pm-r18-test.js && node pm-r19-test.js && node pm-r20-test.js && node pm-r21-test.js && node pm-r22-test.js && node pm-r23-test.js",
18
+ "test:panels": "node pm-shell-test.js && node pm-repos-test.js && node pm-initiatives-test.js && node pm-detail-test.js && node pm-tabs-test.js && node pm-term-test.js && node pm-modes-test.js && node pm-r1-test.js && node pm-r2-test.js && node pm-r3-test.js && node pm-r4-test.js && node pm-r5-test.js && node pm-r6-test.js && node pm-r7-test.js && node pm-r8-test.js && node pm-r9-test.js && node pm-r10-test.js && node pm-r11-test.js && node pm-r12-test.js && node pm-r13-test.js && node pm-r14-test.js && node pm-r15-test.js && node pm-r16-test.js && node pm-r17-test.js && node pm-r16-test.js && node pm-r18-test.js && node pm-r19-test.js && node pm-r20-test.js && node pm-r21-test.js && node pm-r22-test.js && node pm-r23-test.js && node pm-r24-test.js",
19
19
  "test:offline": "node offline-tarball.test.js"
20
20
  },
21
21
  "engines": {
@@ -605,6 +605,69 @@ async function cWatch(on) {
605
605
  const cToggle = (k) => { if (cOpen.has(k)) cOpen.delete(k); else cOpen.add(k); setState({}); };
606
606
  const TYPE_CHIP = 'font-family:var(--mono);font-size:9.5px;padding:1px 6px;border:1px solid var(--border);color:var(--muted-foreground)';
607
607
 
608
+ let jPlan = null; // the backlog plan the PM is looking at: { ws, plan }
609
+ let jNote = null;
610
+ const jSay = (text) => { jNote = text; setState({}); };
611
+ async function jSwitch(on) {
612
+ const ws = vWs(); if (!ws) return;
613
+ const out = await post('/api/claims/jev', { ws, jev: on });
614
+ reload('claims'); jPlan = null;
615
+ 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
+ }
617
+ async function jResume() {
618
+ const ws = vWs(); if (!ws) return;
619
+ const out = await post('/api/claims/jev/resume', { ws });
620
+ reload('claims');
621
+ jSay(out && out.ok ? 'automatic checking with Jev resumed' : ((out && out.error) || 'not resumed'));
622
+ }
623
+ async function jBacklogPlan() {
624
+ const ws = vWs(); if (!ws) return;
625
+ const out = await post('/api/claims/jev/backlog', { ws });
626
+ if (!out || out.ok === false) { jPlan = null; return jSay((out && out.error) || 'no plan'); }
627
+ jPlan = { ws, plan: out.plan }; jSay('review the count, then confirm to send');
628
+ }
629
+ async function jBacklogRun() {
630
+ if (!jPlan) return;
631
+ const { ws, plan } = jPlan;
632
+ jSay('sending ' + plan.requests + ' request(s) to ' + plan.host + ' …');
633
+ const out = await post('/api/claims/jev/backlog', { ws, confirm: true, expect_requests: plan.requests });
634
+ jPlan = null; reload('claims');
635
+ jSay(!out || out.ok === false ? ((out && out.error) || 'the backlog run failed') : 'asked ' + out.sent + ' · ' + out.suspected + ' suspected · ' + out.cached + ' answered from the cache · ' + out.failed + ' failed');
636
+ }
637
+ const lastJevLine = (j) => {
638
+ const l = j && j.last; if (!l) return null;
639
+ const when = String(l.at).replace('T', ' ').slice(0, 16);
640
+ if (l.baseline) return 'Last Jev pass ' + when + ': recorded ' + l.baseline + ' existing pair(s) as the starting point and sent nothing.';
641
+ const w = l.withheld_count != null ? l.withheld_count : (l.withheld || []).length;
642
+ return 'Last Jev pass ' + when + (l.backlog ? ' (backlog)' : '') + ': sent ' + l.sent + ' · answered from the cache ' + l.cached + ' · withheld ' + w + ' · excluded ' + l.excluded + ' · failed ' + l.failed + ' · suspected ' + l.suspected + '.';
643
+ };
644
+ function jevBlock(ws, d) {
645
+ const j = d.jev; if (!j) return [];
646
+ const kids = [];
647
+ kids.push(el('div', { style: LABEL }, iv('jev checks on changed claim pairs')));
648
+ 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
+ kids.push(el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:2px 14px 6px' },
650
+ 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'))));
652
+ kids.push(el('div', { 'data-r24': 'gate', 'data-open': j.open ? 'yes' : 'no', style: NOTE + (j.open ? '' : ';color:var(--foreground)') },
653
+ 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
+ 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)' },
655
+ el('span', null, iv(j.paused.reason)),
656
+ el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r24': 'resume', onClick: jResume }, 'resume')));
657
+ const line = lastJevLine(j);
658
+ if (line) kids.push(el('div', { 'data-r24': 'last', style: NOTE }, iv(line)));
659
+ kids.push(el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:2px 14px 6px' },
660
+ el('button', { style: BTN, hover: 'color:var(--foreground)', disabled: j.switch && j.missing.every((m) => m === 'watch') ? null : 'true', 'data-r24': 'backlog',
661
+ title: 'Shows how many requests the existing pairs would take, and sends only after you confirm that number.', onClick: jBacklogPlan }, 'check the backlog with Jev'),
662
+ jPlan && jPlan.ws === ws ? el('button', { style: BTN + ';color:var(--foreground);border-color:var(--foreground)', hover: 'background:var(--accent)', 'data-r24': 'backlog-run', onClick: jBacklogRun }, 'confirm and send ' + jPlan.plan.requests + ' request(s)') : null));
663
+ if (jPlan && jPlan.ws === ws) {
664
+ const p = jPlan.plan;
665
+ kids.push(el('div', { 'data-r24': 'backlog-plan', style: NOTE }, iv(p.requests + ' request(s), about ' + p.characters.toLocaleString() + ' characters, to ' + p.host + ' · ' + p.pairs + ' pairs in all · ' + p.cached + ' already answered · ' + p.excluded + ' excluded · ' + p.withheld_count + ' withheld by the credential scan.')));
666
+ }
667
+ if (jNote) kids.push(el('div', { 'data-r24': 'note', style: NOTE }, iv(jNote)));
668
+ return kids;
669
+ }
670
+
608
671
  function claimsBlock(ws) {
609
672
  const kids = [];
610
673
  kids.push(el('div', { style: LABEL }, iv('consistency of this repository')));
@@ -621,8 +684,11 @@ function claimsBlock(ws) {
621
684
  + c.internal_only + ' are checked by their own structure only; ' + c.unclassified + ' are not classified and are never compared with other files.';
622
685
  kids.push(el('div', { 'data-r22': 'verdict', 'data-verdict': d.checking ? 'checking' : d.verdict, style: 'padding:6px 14px;border-left:2px solid ' + tone + ';margin:2px 14px 6px' },
623
686
  el('span', { 'data-r22': 'verdict-word', style: 'font-size:15px;font-weight:600;color:' + tone }, iv(word)),
687
+ c.suspected > 0 ? el('span', { 'data-r24': 'suspects', style: 'font-size:13px;color:var(--muted-foreground)' }, iv(' · Jev suspects ' + c.suspected + ' more')) : null,
624
688
  el('span', { 'data-r22': 'verdict-why', style: 'display:block;font-size:11.5px;color:var(--muted-foreground);line-height:1.5;margin-top:2px' }, iv(why))));
625
- kids.push(el('div', { 'data-r22': 'limits', style: NOTE }, iv('Checked by structure and the claims record. Prose is not read, so “Consistent” means no recorded statement contradicts its source — not that every sentence was checked. Nothing here is sent anywhere.')));
689
+ const sends = !!(d.jev && d.jev.open);
690
+ kids.push(el('div', { 'data-r22': 'limits', style: NOTE }, iv('Checked by structure and the claims record. Prose is not read, so “Consistent” means no recorded statement contradicts its source — not that every sentence was checked. '
691
+ + (sends ? 'Changed claim pairs are sent to TypeSafe (Jev); everything else stays on this computer.' : 'Nothing here is sent anywhere.'))));
626
692
  kids.push(el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:2px 14px 6px' },
627
693
  el('button', { style: BTN, hover: 'color:var(--foreground)', 'data-r22': 'check', disabled: d.checking ? 'true' : null, onClick: cCheck }, 'check now'),
628
694
  el('button', { style: BTN + (d.watching ? ';color:var(--foreground);border-color:var(--foreground)' : ''), hover: 'color:var(--foreground)', 'data-r22': 'watch', onClick: () => cWatch(!d.watching),
@@ -632,6 +698,7 @@ function claimsBlock(ws) {
632
698
  if (cNote) kids.push(el('div', { 'data-r22': 'note', style: NOTE }, iv(cNote)));
633
699
  if (d.ledger && d.verdict !== 'not-checked') kids.push(el('div', { style: NOTE }, iv('Record of problems: '), el('button', { style: 'text-decoration:underline;color:var(--foreground)', 'data-r22': 'ledger', onClick: () => openDocIn(ws, d.ledger, state.product) }, iv(d.ledger))));
634
700
 
701
+ kids.push(...jevBlock(ws, d));
635
702
  const docs = d.docs || [];
636
703
  const finding = (f, rel) => el('div', { 'data-r22': 'finding', 'data-status': f.status, style: 'padding:2px 14px 2px 26px;font-size:11.5px;line-height:1.5;color:' + (f.status === 'contradicted' ? 'var(--foreground)' : 'var(--muted-foreground)') }, iv(f.sentence));
637
704
  const docRow = (x, showFindings) => el('div', { 'data-r22': 'doc', 'data-rel': x.rel, style: 'padding:4px 0;border-bottom:1px solid oklch(1 0 0 / 5%)' },
@@ -651,6 +718,7 @@ function claimsBlock(ws) {
651
718
  const only = (st) => (x) => ({ ...x, findings: x.findings.filter((f) => f.status === st) });
652
719
  kids.push(group('problems', 'problems — a statement is contradicted by its source', withStatus('contradicted').map(only('contradicted')), 'always', true));
653
720
  kids.push(group('stale', 'stale — a source changed after the document did, or its date passed', withStatus('stale').map(only('stale')), 'always', true));
721
+ kids.push(group('suspected', 'suspected by Jev — advisory, not counted as problems', withStatus('suspected').map(only('suspected')), 'always', true));
654
722
  kids.push(group('undecided', 'worth a look — structure cannot decide', withStatus('undecided').map(only('undecided')), 'toggle', true));
655
723
  kids.push(group('fine', 'checked and fine', docs.filter((x) => x.classified && !x.findings.length), 'toggle', false));
656
724
  kids.push(group('unclassified', 'not classified — nothing declares what these are', docs.filter((x) => !x.classified), 'toggle', true));
@@ -64,6 +64,7 @@ const { checkSurface } = require('./lib/surface-contract');
64
64
  const { createSinkGuard } = require('./lib/sink.js');
65
65
  const tsSettings = require('./lib/typesafe-settings.js');
66
66
  const tsClient = require('./lib/typesafe.js');
67
+ const jevClaims = require('./lib/jev-claims.js'); // D80 — the bridge between the claims ledger and the one TypeSafe module
67
68
  const validateLib = require('./lib/validate.js');
68
69
  const claimsLib = require('./lib/claims.js');
69
70
  const repoWatch = require('./lib/repo-watch.js');
@@ -4476,9 +4477,24 @@ async function runClaims(w, { sweep = false } = {}) {
4476
4477
  try {
4477
4478
  const { cacheDir, ledgerFile, ledgerDir } = claimsPaths(w);
4478
4479
  try { fs.mkdirSync(ledgerDir, { recursive: true }); fs.mkdirSync(cacheDir, { recursive: true }); } catch { /* the writers report it */ }
4479
- return await claimsLib.check({ root: w.root, cacheDir, ledgerFile, sweep });
4480
+ const out = await claimsLib.check({ root: w.root, cacheDir, ledgerFile, sweep });
4481
+ // D80 — after the local check, and only when all four gates hold (the bridge decides), ask Jev about changed claim pairs.
4482
+ if (out.ok) {
4483
+ const f = tsSettings.getRepo(STATE, w.root);
4484
+ 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 });
4485
+ const fresh = claimsLib.readView({ cacheDir, ledgerFile });
4486
+ return { ...out, ...fresh, jev };
4487
+ }
4488
+ return out;
4480
4489
  } finally { claimsRunning.delete(w.root); }
4481
4490
  }
4491
+ // D80 — what the page needs to explain the Jev switch: which gates hold, and what the last pass did.
4492
+ function jevGateInfo(w) {
4493
+ const f = tsSettings.getRepo(STATE, w.root);
4494
+ const g = jevClaims.gates({ key: tsSettings.status(STATE).present, consent: f.consent, watch: f.watch, jev: f.jev });
4495
+ const { cacheDir } = claimsPaths(w);
4496
+ return { switch: f.jev, open: g.open, missing: g.missing, sentence: g.open ? null : jevClaims.gateSentence(g.missing), ...jevClaims.status(cacheDir) };
4497
+ }
4482
4498
  const claimsLast = new Map(); // root → { at, run, error }
4483
4499
  const claimsWatcher = repoWatch.createWatcher({
4484
4500
  repos: () => Array.from(workspaces.values()).map((w) => ({ id: w.id, root: w.root })),
@@ -6077,7 +6093,7 @@ async function handleRequest(req, res) {
6077
6093
  const { cacheDir, ledgerFile } = claimsPaths(w);
6078
6094
  const last = claimsLast.get(w.root) || null;
6079
6095
  return json(res, { ok: true, ws: w.id, name: w.name, ...claimsLib.readView({ cacheDir, ledgerFile }),
6080
- watching: tsSettings.getRepo(STATE, w.root).watch === true, checking: claimsRunning.has(w.root), last_run: last,
6096
+ watching: tsSettings.getRepo(STATE, w.root).watch === true, checking: claimsRunning.has(w.root), last_run: last, jev: jevGateInfo(w),
6081
6097
  // the record's path, only when it is inside the repository AND something has been written to it
6082
6098
  ledger: (path.relative(w.root, ledgerFile).startsWith('..') || !fs.existsSync(ledgerFile)) ? null : path.relative(w.root, ledgerFile).split(path.sep).join('/') });
6083
6099
  }
@@ -6102,6 +6118,45 @@ async function handleRequest(req, res) {
6102
6118
  if (!out.ok) return json(res, { ok: false, error: out.error }, 500);
6103
6119
  return json(res, { ok: true, ws: w.id, watching: tsSettings.getRepo(STATE, w.root).watch === true });
6104
6120
  }
6121
+ // ── D80 — Jev on changed claim pairs: the switch, a resume after a pause, and the backlog (plan, then confirmed run) ──
6122
+ if (p === '/api/claims/jev' && req.method === 'POST') {
6123
+ let body = {};
6124
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6125
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6126
+ const w = getWs(body.ws);
6127
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6128
+ if (typeof body.jev !== 'boolean') return json(res, { ok: false, error: 'jev must be true or false' }, 400);
6129
+ const out = tsSettings.setRepo(STATE, w.root, { jev: body.jev });
6130
+ if (!out.ok) return json(res, { ok: false, error: out.error }, 500);
6131
+ return json(res, { ok: true, ws: w.id, jev: jevGateInfo(w) });
6132
+ }
6133
+ if (p === '/api/claims/jev/resume' && req.method === 'POST') {
6134
+ let body = {};
6135
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6136
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6137
+ const w = getWs(body.ws);
6138
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6139
+ jevClaims.resume(claimsPaths(w).cacheDir);
6140
+ return json(res, { ok: true, ws: w.id, jev: jevGateInfo(w) });
6141
+ }
6142
+ if (p === '/api/claims/jev/backlog' && req.method === 'POST') {
6143
+ let body = {};
6144
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
6145
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6146
+ const w = getWs(body.ws);
6147
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 400);
6148
+ const f = tsSettings.getRepo(STATE, w.root);
6149
+ const g = jevClaims.gates({ key: tsSettings.status(STATE).present, consent: f.consent, watch: true, jev: f.jev });
6150
+ if (!g.open) return json(res, { ok: false, error: jevClaims.gateSentence(g.missing) }, 409);
6151
+ const { cacheDir, ledgerFile } = claimsPaths(w);
6152
+ if (body.confirm !== true) return json(res, { ok: true, ws: w.id, plan: jevClaims.backlogPlan({ root: w.root, cacheDir, exclude: f.exclude }) });
6153
+ if (claimsRunning.has(w.root)) return json(res, { ok: false, busy: true, error: 'a check is already running for this repository' }, 409);
6154
+ claimsRunning.add(w.root);
6155
+ try {
6156
+ const out = await jevClaims.runBacklog({ root: w.root, cacheDir, ledgerFile, key: tsSettings.getKey(STATE), flags: f, exclude: f.exclude, expectRequests: body.expect_requests });
6157
+ return json(res, { ws: w.id, ...out, jev: jevGateInfo(w), ...claimsLib.readView({ cacheDir, ledgerFile }) }, out.ok ? 200 : (out.conflict ? 409 : 500));
6158
+ } finally { claimsRunning.delete(w.root); }
6159
+ }
6105
6160
  // ── D79 — repository validation through TypeSafe ──────────────────────────────────────────────────
6106
6161
  // The ONLY routes that lead to an outbound request, and only through lib/typesafe.js. The key is never
6107
6162
  // in a response: `status` says present or absent. Nothing here edits a repository file.