create-pmos 0.46.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 (83) 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 +13 -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/handover-run.mjs +33 -0
  11. package/payload/mods/pmos-cockpit/hooks/lib/handover.mjs +267 -0
  12. package/payload/mods/pmos-cockpit/hooks/register.tsx +189 -18
  13. package/payload/mods/pmos-cockpit/types/index.d.ts +16 -1
  14. package/payload/okf/core/concepts/frontmatter-format.md +6 -1
  15. package/payload/planning/evals/eval-calibration.md +1 -1
  16. package/payload/planning/okrs/health-budgets.md +1 -1
  17. package/payload/scripts/pmos-log.sh +18 -8
  18. package/payload/scripts/pmos-model.js +233 -2
  19. package/payload/scripts/status-facts.js +204 -0
  20. package/payload/scripts/workflow-state.js +52 -0
  21. package/payload/skills/SKILL-FORMAT.md +1 -0
  22. package/payload/state/discoveries.md +1 -1
  23. package/workspace/README.md +3 -3
  24. package/workspace/bin/live-notes.js +133 -0
  25. package/workspace/design-parity.js +328 -21
  26. package/workspace/doc-category-bench.js +165 -0
  27. package/workspace/fixtures/doc-category-labelled.json +167 -0
  28. package/workspace/guide/initiatives-and-next-steps.md +16 -2
  29. package/workspace/guide/knowledge-graph.md +89 -2
  30. package/workspace/guide/layout.md +30 -2
  31. package/workspace/guide/proactive-engine.md +26 -4
  32. package/workspace/guide/scheduled-tasks-and-cli.md +1 -0
  33. package/workspace/guide/statuses.md +1 -0
  34. package/workspace/guide/validate-with-typesafe.md +17 -1
  35. package/workspace/lib/claims.js +51 -2
  36. package/workspace/lib/doc-category-corrections.js +113 -0
  37. package/workspace/lib/doc-category-writeback.js +110 -0
  38. package/workspace/lib/doc-category.js +225 -0
  39. package/workspace/lib/engine/pmos-model.js +233 -2
  40. package/workspace/lib/engine/status-facts.js +204 -0
  41. package/workspace/lib/engine/workflow-state.js +52 -0
  42. package/workspace/lib/jev-categorize.js +399 -0
  43. package/workspace/lib/jev-claims.js +59 -15
  44. package/workspace/lib/jev-route.js +129 -0
  45. package/workspace/lib/kg-cli.js +5 -2
  46. package/workspace/lib/kg-dangling-baseline.json +1 -0
  47. package/workspace/lib/kg-dangling-rules.json +1 -0
  48. package/workspace/lib/kg.js +26 -9
  49. package/workspace/lib/learn.js +282 -0
  50. package/workspace/lib/live-notes.js +890 -0
  51. package/workspace/lib/proactive-cli.js +49 -1
  52. package/workspace/lib/proactive.js +8 -7
  53. package/workspace/lib/typesafe-settings.js +10 -2
  54. package/workspace/package-lock.json +1 -1
  55. package/workspace/package.json +5 -4
  56. package/workspace/public/pm/app.js +148 -79
  57. package/workspace/public/pm/data.js +58 -0
  58. package/workspace/public/pm/fixture.js +40 -2
  59. package/workspace/public/pm/modes-live.js +267 -21
  60. package/workspace/public/pm/panel-cockpit.js +636 -0
  61. package/workspace/public/pm/panel-detail.js +210 -8
  62. package/workspace/public/pm/panel-initiatives.js +9 -9
  63. package/workspace/public/pm/panel-newsess.js +145 -86
  64. package/workspace/public/pm/panel-repos.js +34 -17
  65. package/workspace/public/pm/panel-tabs.js +145 -27
  66. package/workspace/public/pm/state.js +45 -4
  67. package/workspace/public/pm/tab-doc.js +244 -16
  68. package/workspace/public/pm/tab-term.js +11 -1
  69. package/workspace/public/pm/tokens.css +4 -3
  70. package/workspace/public/pm/view-addrepo.js +207 -0
  71. package/workspace/public/pm/view-files.js +197 -0
  72. package/workspace/public/pm/view-guide.js +141 -0
  73. package/workspace/public/pm/view-knowledge.js +225 -0
  74. package/workspace/public/pm/view-memory.js +122 -0
  75. package/workspace/public/pm/view-portfolio.js +153 -0
  76. package/workspace/public/pm/view-reports.js +190 -0
  77. package/workspace/public/pm/view-scheduled.js +161 -0
  78. package/workspace/public/pm/view-skills.js +216 -0
  79. package/workspace/public/pm/view-today.js +168 -0
  80. package/workspace/public/pm/view-validate.js +171 -0
  81. package/workspace/server.js +394 -10
  82. package/workspace/skills/live-notes.skill +58 -0
  83. package/workspace/skills/morning-brief.skill +1 -0
@@ -0,0 +1,890 @@
1
+ 'use strict';
2
+ /* live-notes.js — map the talking points of a conversation to initiatives, as proposals only (jev-live-notes, Run 1).
3
+ *
4
+ * THE UNIT IS A SEGMENT: one short run of text, a time, an optional speaker, a pointer to where it came from. Nothing
5
+ * here records audio or depends on one source. A segment is placed against the repository's open initiatives and
6
+ * given a relation (D71's words plus `answers`), a scope flag and a confirmed flag, then appended to a ledger.
7
+ *
8
+ * THREE PATHS, ONE OUTPUT SHAPE.
9
+ * agent-only (the default): the engine writes what to ask an agent session; the answers come back as
10
+ * `LIVENOTE {json}` lines and are checked here. No outside service.
11
+ * jev-agent (Run 5): when ALL THREE gates hold — a key, D79 consent for the repository, and the "live notes may use
12
+ * Jev" switch — and no sender is injected, the same prompt asks the PM's own agent session to make the two choices
13
+ * with the Typesafe skill (the route `judgment.js` already uses) and to mark each answer `"by":"jev"`. The
14
+ * workspace sends nothing itself; no new server egress, so D79 is not amended.
15
+ * jev: the same three gates, through the `ask` function a caller injects. This file requires no network module,
16
+ * and no command injects one: a server-side send of live talk needs a decision that amends D79.
17
+ *
18
+ * WHAT IT NEVER DOES. It creates, edits, accepts or closes no initiative and writes no decision. Its only writes into
19
+ * the repository are the session ledger (`<state>/live-notes/<session>.jsonl`, through lib/sink.js) and, on close,
20
+ * one section appended to the day's requirements note (D71). The raw transcript lives only in the per-user runtime
21
+ * directory and is deleted on close, or after a retention the PM sets. A result is advisory and is shown in words.
22
+ *
23
+ * KNOWN WEAKNESS, KEPT VISIBLE. The relation, scope, confirmed and answers questions are asked in the same request as
24
+ * the initiative choice, so they refer to "the initiative this point is most about". That is the indirection Jev is
25
+ * documented to handle less well. A weak or inconsistent initiative choice is re-asked once and, if still weak, is
26
+ * kept as a flagged proposal rather than a match. Whether this holds on real talk is the override rate's question.
27
+ */
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const crypto = require('crypto');
31
+ const { scan } = require('./secret-scan');
32
+ const { createSinkGuard } = require('./sink');
33
+
34
+ const MAX_CHOICE = 255; // the documented cap on options in one Choice
35
+ const MAX_ANSWER_QS = 3; // open questions asked about per segment
36
+ const REJECTED_LIMIT = 2;
37
+ const UNREACHABLE_LIMIT = 3;
38
+ const MAX_QUOTE = 160;
39
+ const MIN_SEGMENT_CHARS = 8;
40
+ const RELATIONS = ['validates', 'clarifies', 'extends', 'contradicts', 'answers', 'none'];
41
+ const RELATION_HELP = {
42
+ validates: 'The point supports or confirms something the initiative already says.',
43
+ clarifies: 'The point explains or sharpens something the initiative already says.',
44
+ extends: 'The point adds something the initiative does not yet cover.',
45
+ contradicts: 'The point conflicts with, or invalidates, something the initiative says.',
46
+ answers: 'The point answers a question the initiative has left open.',
47
+ none: 'The point has no clear relation to the initiative.',
48
+ };
49
+ const LANES = ['feature', 'discovery', 'chore', 'incident', 'content', 'prototype']; // the code profile's six (D56, D58, D60)
50
+ const MAX_NOTE = 600;
51
+ const MAX_TITLE = 80;
52
+ const MAX_CITE_FILE = 400 * 1024;
53
+ const STOP = new Set('about above after again also been being from have into just more most only other over some such than that their them then there these they this those very what when where which while will with would your you are and but for not the was were can could should how why who has had its our out all any'.split(' '));
54
+
55
+ const sha = (s) => crypto.createHash('sha1').update(String(s)).digest('hex');
56
+ const nowISO = (t) => new Date(t == null ? Date.now() : t).toISOString();
57
+ const oneLine = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
58
+
59
+ /** Words, never the number. Below 0.5 the claim is not made. */
60
+ const band = (p) => (p >= 0.85 ? 'very likely' : p >= 0.65 ? 'likely' : p >= 0.5 ? 'possible' : 'unlikely');
61
+
62
+ /* ── the gates ─────────────────────────────────────────────────────────────────────────────────── */
63
+ function gates({ key, consent, live }) {
64
+ const missing = [];
65
+ if (!key) missing.push('key');
66
+ if (!consent) missing.push('consent');
67
+ if (!live) missing.push('live');
68
+ return { open: missing.length === 0, missing };
69
+ }
70
+ const GATE_WORDS = {
71
+ key: 'No TypeSafe key is saved.',
72
+ consent: 'Validation is not switched on for this repository.',
73
+ live: 'Live notes are not switched on to use Jev.',
74
+ };
75
+ const gateSentence = (missing) => missing.map((m) => GATE_WORDS[m]).join(' ');
76
+
77
+ /* ── per-user switch: default OFF, kept outside every repository ─────────────────────────────── */
78
+ const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
79
+ function writeJson(f, v) {
80
+ fs.mkdirSync(path.dirname(f), { recursive: true });
81
+ const tmp = f + '.' + process.pid + '.tmp';
82
+ fs.writeFileSync(tmp, JSON.stringify(v));
83
+ fs.renameSync(tmp, f);
84
+ }
85
+ const switchFile = (runtimeDir) => path.join(runtimeDir, 'live-notes', 'settings.json');
86
+ function getSwitch(runtimeDir, root) {
87
+ const s = readJson(switchFile(runtimeDir), {});
88
+ const r = s && s.repos && s.repos[root];
89
+ return { live: !!(r && r.live === true), retention_days: r && Number.isFinite(r.retention_days) && r.retention_days > 0 ? Math.min(r.retention_days, 365) : 0 };
90
+ }
91
+ function setSwitch(runtimeDir, root, { live, retention_days }) {
92
+ const s = readJson(switchFile(runtimeDir), {});
93
+ const prev = getSwitch(runtimeDir, root);
94
+ const repos = { ...(s && s.repos), [root]: {
95
+ live: typeof live === 'boolean' ? live : prev.live,
96
+ retention_days: Number.isFinite(retention_days) && retention_days >= 0 ? Math.min(retention_days, 365) : prev.retention_days,
97
+ } };
98
+ writeJson(switchFile(runtimeDir), { repos });
99
+ return getSwitch(runtimeDir, root);
100
+ }
101
+
102
+ /* ── reading the repository (read-only) ────────────────────────────────────────────────────────── */
103
+ function frontmatter(text) {
104
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
105
+ const fm = {};
106
+ if (!m) return fm;
107
+ for (const line of m[1].split(/\r?\n/)) {
108
+ const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
109
+ if (kv) fm[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, '');
110
+ }
111
+ return fm;
112
+ }
113
+ const stateDirIn = (root) => (fs.existsSync(path.join(root, 'pmos', 'state')) ? path.join(root, 'pmos', 'state') : path.join(root, 'planning'));
114
+ const rel = (root, f) => path.relative(root, f).split(path.sep).join('/');
115
+
116
+ /** An entry is a path prefix, or a `*` pattern on the path (the same rule as the repository exclusions). */
117
+ function isExcluded(exclude, relPath) {
118
+ const p = String(relPath).replace(/\\/g, '/');
119
+ return (exclude || []).some((e) => {
120
+ if (e.includes('*')) {
121
+ const re = new RegExp('^' + e.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$');
122
+ return re.test(p);
123
+ }
124
+ return p === e || p.startsWith(e.endsWith('/') ? e : e + '/');
125
+ });
126
+ }
127
+
128
+ /** Initiatives not stopped under D70 (the latest lifecycle line wins; `active` reopens). */
129
+ function loadInitiatives(root) {
130
+ const dir = path.join(root, 'planning', 'initiatives');
131
+ let files = [];
132
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.md') && !/^(README|index|log)\.md$/i.test(f)); } catch { /* none */ }
133
+ const stopped = new Map();
134
+ const lf = path.join(root, 'planning', 'runs', 'lifecycle.jsonl');
135
+ let raw = '';
136
+ try { raw = fs.readFileSync(lf, 'utf8'); } catch { /* none */ }
137
+ for (const line of raw.split('\n')) {
138
+ if (!line.trim()) continue;
139
+ let e = null; try { e = JSON.parse(line); } catch { continue; }
140
+ if (e && e.initiative_id) stopped.set(e.initiative_id, e.state);
141
+ }
142
+ const all = [];
143
+ for (const f of files.sort()) {
144
+ let text = '';
145
+ try { text = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; }
146
+ const fm = frontmatter(text);
147
+ const id = fm.id || f.replace(/\.md$/, '');
148
+ const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
149
+ const para = body.split(/\n\s*\n/).map((p) => p.trim()).find((p) => p && !/^#/.test(p) && !/^[-*]\s/.test(p)) || '';
150
+ all.push({ id, title: fm.title || id, anchor: fm.parent_kr || '', prd: fm.prd_path || '', intent: oneLine(para).slice(0, 240), state: stopped.get(id) || 'active' });
151
+ }
152
+ const open = all.filter((i) => i.state === 'active');
153
+ return { open, stopped: all.filter((i) => i.state !== 'active').map((i) => i.id) };
154
+ }
155
+
156
+ /** Filter the open set in code. `anchor` is exact (`domain:pmos`, `health:tech`, a KR id). Past the Choice cap: stop, never truncate. */
157
+ function shortlist(open, { anchor = null } = {}) {
158
+ const list = anchor ? open.filter((i) => i.anchor === anchor) : open.slice();
159
+ if (list.length > MAX_CHOICE) {
160
+ return { ok: false, count: list.length, error: `${list.length} open initiatives match, and one Choice holds at most ${MAX_CHOICE}. Narrow it with an anchor (for example domain:<id>); nothing was truncated.` };
161
+ }
162
+ return { ok: true, list };
163
+ }
164
+
165
+ /** Open questions by structure: the top-level items under an "Open questions" or "Open points" heading. */
166
+ function openQuestionsOf(prdText) {
167
+ const lines = String(prdText || '').split(/\r?\n/);
168
+ const out = [];
169
+ let level = 0;
170
+ for (const line of lines) {
171
+ const h = /^(#{1,6})\s+(.*)$/.exec(line);
172
+ if (h) {
173
+ if (level && h[1].length <= level) break;
174
+ if (!level && /^open (questions|points)\b/i.test(h[2].trim())) level = h[1].length;
175
+ continue;
176
+ }
177
+ if (!level) continue;
178
+ const m = /^(?:\d+[.)]|[-*])\s+(.*)$/.exec(line);
179
+ if (m) out.push(oneLine(m[1].replace(/\*\*|__|`/g, '')).slice(0, 300));
180
+ }
181
+ return out.filter(Boolean);
182
+ }
183
+ function attachQuestions(root, items, exclude) {
184
+ return items.map((i) => {
185
+ let qs = [];
186
+ if (i.prd && !isExcluded(exclude, i.prd)) {
187
+ try { qs = openQuestionsOf(fs.readFileSync(path.join(root, i.prd), 'utf8')); } catch { /* no PRD */ }
188
+ }
189
+ return { ...i, questions: qs };
190
+ });
191
+ }
192
+
193
+ /* ── the request ───────────────────────────────────────────────────────────────────────────────── */
194
+ const terms = (s) => new Set(String(s).toLowerCase().match(/[a-z][a-z0-9-]{3,}/g)?.filter((w) => !STOP.has(w)) || []);
195
+ const overlap = (a, b) => { let n = 0; for (const t of a) if (b.has(t)) n++; return n; };
196
+
197
+ /** Deterministic shuffle: Jev leans toward the first option, so the order changes with the pass. */
198
+ function shuffled(arr, seed) {
199
+ return arr.map((x, i) => [sha(seed + '|' + i + '|' + (x.id || x)), x]).sort((a, b) => (a[0] < b[0] ? -1 : 1)).map((p) => p[1]);
200
+ }
201
+
202
+ /** The open questions worth asking about for this segment: ranked by shared words in code, a bounded few. */
203
+ function pickQuestions(items, text) {
204
+ const t = terms(text);
205
+ const all = [];
206
+ for (const i of items) (i.questions || []).forEach((q, n) => all.push({ initiative: i.id, n: n + 1, text: q, score: overlap(t, terms(q)) }));
207
+ return all.filter((q) => q.score > 0).sort((a, b) => b.score - a.score || (a.initiative + a.n < b.initiative + b.n ? -1 : 1)).slice(0, MAX_ANSWER_QS);
208
+ }
209
+
210
+ function requestFor(segment, items, pass = 0) {
211
+ const asked = pickQuestions(items, segment.text);
212
+ const ordered = shuffled(items, segment.id + '#' + pass);
213
+ const criteria = {};
214
+ for (const i of ordered) criteria[i.id] = oneLine(i.title + (i.intent ? ' — ' + i.intent : '')).slice(0, 300);
215
+ criteria.none = 'None of these initiatives.';
216
+ const rel = {};
217
+ for (const r of shuffled(RELATIONS, segment.id + '@' + pass)) rel[r] = RELATION_HELP[r];
218
+ const questions = {
219
+ initiative: { type: 'choice', instructions: 'Which initiative is this talking point about?', criteria },
220
+ relation: { type: 'choice', instructions: 'How does the talking point relate to the initiative it is most about?', criteria: rel },
221
+ in_scope: { type: 'noul', instructions: 'The talking point is inside the stated scope of the initiative it is most about.' },
222
+ confirmed: { type: 'noul', instructions: 'The speaker states this as decided or agreed, not as an idea, a question or an opinion.' },
223
+ };
224
+ asked.forEach((q, k) => { questions['answers_' + (k + 1)] = { type: 'noul', instructions: `The talking point answers this open question: "${q.text}"` }; });
225
+ const state = 'TALKING POINT:\n' + (segment.speaker ? segment.speaker + ': ' : '') + segment.text;
226
+ return { state, questions, asked };
227
+ }
228
+
229
+ /* ── answers → proposal ────────────────────────────────────────────────────────────────────────── */
230
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1 ? v : NaN);
231
+ function choiceOf(a, allowed) {
232
+ if (!a || a.type !== 'choice' || typeof a.choice !== 'string' || !allowed.includes(a.choice)) return null;
233
+ const p = a.probabilities && Number.isFinite(a.probabilities[a.choice]) ? a.probabilities[a.choice] : num(a.confidence);
234
+ return { choice: a.choice, p: Number.isFinite(p) ? p : 0, confidence: num(a.confidence) };
235
+ }
236
+ function weak(c) { return !c || c.p < 0.5 || (c.confidence != null && c.confidence < 0.5); }
237
+
238
+ function mapJev(answers, req, ids) {
239
+ const ini = choiceOf(answers && answers.initiative, ids.concat('none'));
240
+ const rel = choiceOf(answers && answers.relation, RELATIONS);
241
+ const scope = num(answers && answers.in_scope && answers.in_scope.noul);
242
+ const conf = num(answers && answers.confirmed && answers.confirmed.noul);
243
+ if (!ini || !rel || !Number.isFinite(scope) || !Number.isFinite(conf)) return null;
244
+ let best = null;
245
+ req.asked.forEach((q, k) => {
246
+ const v = num(answers['answers_' + (k + 1)] && answers['answers_' + (k + 1)].noul);
247
+ if (Number.isFinite(v) && v >= 0.65 && (!best || v > best.p)) best = { initiative: q.initiative, n: q.n, text: q.text, p: v };
248
+ });
249
+ return { ini, rel, scope, conf, answered: best };
250
+ }
251
+
252
+ /** The proposal fields shared by both paths. `flags` records anything the PM should read twice. */
253
+ function proposalFrom(m, ctx) {
254
+ const flags = ctx.flags.slice();
255
+ let initiative = m.ini.choice;
256
+ if (weak(m.ini) || ctx.inconsistent) { flags.push(ctx.inconsistent ? 'initiative-choice-inconsistent' : 'initiative-choice-weak'); initiative = 'none'; }
257
+ let relation = m.rel.choice;
258
+ let answers_question = null;
259
+ if (m.answered && initiative === m.answered.initiative) {
260
+ answers_question = { initiative: m.answered.initiative, n: m.answered.n, text: m.answered.text.slice(0, 200), band: band(m.answered.p) };
261
+ relation = 'answers';
262
+ } else if (relation === 'answers') { relation = 'clarifies'; flags.push('answers-without-a-named-question'); }
263
+ if (initiative === 'none') relation = 'none';
264
+ return {
265
+ initiative, initiative_band: band(m.ini.p),
266
+ relation, relation_band: band(m.rel.p),
267
+ in_scope: m.scope >= 0.5, scope_band: band(m.scope),
268
+ confidence: m.conf >= 0.85 ? 'confirmed' : 'unconfirmed',
269
+ answers_question, flags,
270
+ };
271
+ }
272
+
273
+ /** `LIVENOTE {json}` lines from an agent session. Anything off-shape is rejected, never repaired. */
274
+ const JUDGES = ['jev', 'agent'];
275
+ function parseAgentAnswers(text, ids) {
276
+ const out = new Map(); const rejected = [];
277
+ for (const line of String(text || '').split(/\r?\n/)) {
278
+ const m = /^\s*LIVENOTE\s+(\{.*\})\s*$/.exec(line);
279
+ if (!m) continue;
280
+ let o = null; try { o = JSON.parse(m[1]); } catch { rejected.push('not JSON'); continue; }
281
+ const ok = o && typeof o.segment === 'string' && (o.initiative === 'none' || ids.includes(o.initiative)) && RELATIONS.includes(o.relation)
282
+ && typeof o.in_scope === 'boolean' && typeof o.confirmed === 'boolean' && Number.isFinite(o.confidence) && o.confidence >= 0 && o.confidence <= 1
283
+ && (o.by === undefined || JUDGES.includes(o.by));
284
+ if (!ok) { rejected.push(String(o && o.segment || '?')); continue; }
285
+ out.set(o.segment, o);
286
+ }
287
+ return { answers: out, rejected };
288
+ }
289
+ function proposalFromAgent(o, { jevAllowed = false } = {}) {
290
+ const flags = [];
291
+ // An answer that says Jev made it, when the gates were not open, is kept and flagged, never hidden: the text has
292
+ // already left the machine, and the PM is the one who needs to know.
293
+ if (o.by === 'jev' && !jevAllowed) flags.push('jev-used-without-gates');
294
+ let relation = o.initiative === 'none' ? 'none' : o.relation;
295
+ const a = o.answers && typeof o.answers === 'string' ? o.answers : null;
296
+ if (relation === 'answers' && !a) { relation = 'clarifies'; flags.push('answers-without-a-named-question'); }
297
+ return {
298
+ initiative: o.initiative, initiative_band: band(o.confidence), relation, relation_band: band(o.confidence),
299
+ in_scope: o.in_scope, scope_band: band(o.confidence), confidence: o.confirmed && o.confidence >= 0.85 ? 'confirmed' : 'unconfirmed',
300
+ answers_question: a ? { initiative: o.initiative, n: null, text: a.slice(0, 200), band: band(o.confidence) } : null, flags,
301
+ judged_by: o.by === 'jev' ? 'jev' : 'agent',
302
+ };
303
+ }
304
+
305
+ /** The prompt an agent session answers. With `jev`, the session is asked to make the two choices with the Typesafe skill. */
306
+ const JEV_AGENT_LINES = [
307
+ 'Use the Typesafe skill (/typesafe:typesafe-ai) to choose the initiative and the relation for each talking point: one Choice question for the initiative ids plus "none", one for the relation words.',
308
+ 'Send only the talking point\'s words and the option list. Do not send a speaker\'s name, a file, or a key. The words leave this machine for the Typesafe service, which needs TYPESAFE_API_KEY.',
309
+ 'Give Jev\'s probability for its choice as "confidence" and write "by":"jev". If the skill or the key is missing, or a call fails, say so, decide yourself, and write "by":"agent".',
310
+ ];
311
+ function agentPrompt(segments, items, { jev = false } = {}) {
312
+ const lines = ['For each talking point below, decide which initiative it is about, how it relates, whether it is in that initiative\'s scope, and whether the speaker stated it as decided.'];
313
+ if (jev) lines.push(...JEV_AGENT_LINES);
314
+ else lines.push('Decide yourself; no outside service is used for these points. Write "by":"agent".');
315
+ lines.push('Initiatives (use the id, or "none"):');
316
+ for (const i of items) {
317
+ lines.push(`- ${i.id}: ${oneLine(i.title)}${i.intent ? ' — ' + i.intent : ''}`);
318
+ (i.questions || []).slice(0, 5).forEach((q, n) => lines.push(` open question ${n + 1}: ${q}`));
319
+ }
320
+ lines.push(`relation is one of: ${RELATIONS.join(', ')}. Use "answers" only when the point answers a named open question, and name it in "answers".`);
321
+ lines.push('Print one line per talking point, exactly like this, one JSON object per line (confidence is yours, 0 to 1):');
322
+ lines.push(` e.g. LIVENOTE {"segment":"<id>","initiative":"<id or none>","relation":"<one of the words>","in_scope":true,"confirmed":false,"answers":null,"confidence":0.7,"by":"${jev ? 'jev' : 'agent'}"}`);
323
+ lines.push('Talking points:');
324
+ // With Jev the speaker is left out of the prompt, so it cannot be sent by mistake.
325
+ for (const s of segments) lines.push(`[${s.id}] ${s.speaker && !jev ? s.speaker + ': ' : ''}${s.text}`);
326
+ return lines.join('\n');
327
+ }
328
+
329
+
330
+ /* ── Run 3: the agent's context note and candidate draft, by tier ────────────────────────────────── */
331
+ const MODEL_PATHS = [path.join(__dirname, '..', '..', 'scripts', 'pmos-model.js'), path.join(__dirname, 'engine', 'pmos-model.js')];
332
+ function modelCore() { for (const p of MODEL_PATHS) { try { if (fs.existsSync(p)) return require(p); } catch { /* not loadable */ } } return null; }
333
+
334
+ /** The latest row per segment (a pending row is superseded by the proposal that answers it). */
335
+ function foldSegments(rows) { const m = new Map(); for (const r of rows) if (r.row === 'segment') m.set(r.segment, r); return m; }
336
+ function foldCandidates(rows) { const m = new Map(); for (const r of rows) if (r.row === 'candidate') m.set(r.candidate, r); return m; }
337
+ function acceptedInitiatives(root) {
338
+ const out = new Set();
339
+ let raw = ''; try { raw = fs.readFileSync(path.join(root, 'planning', 'runs', 'acceptances.jsonl'), 'utf8'); } catch { return out; }
340
+ for (const line of raw.split('\n')) { if (!line.trim()) continue; try { const e = JSON.parse(line); if (e && e.accepted === true && e.initiative_id) out.add(e.initiative_id); } catch { /* torn */ } }
341
+ return out;
342
+ }
343
+
344
+ /** What needs a note, and on which tier. Tagging itself is `fast` and is not listed. */
345
+ function contextPlan({ root, rows, items = [] }) {
346
+ const accepted = acceptedInitiatives(root);
347
+ const byId = new Map(items.map((i) => [i.id, i]));
348
+ const plan = { tagging_tier: 'fast', context: [], candidates: [] };
349
+ const decided = new Set(rows.filter((r) => r.row === 'context').map((r) => r.segment));
350
+ for (const [id, r] of foldSegments(rows)) {
351
+ if (r.status !== 'proposed' || r.initiative === 'none' || !r.initiative) continue;
352
+ const reasons = [];
353
+ if (r.relation === 'contradicts') reasons.push('it contradicts the initiative');
354
+ if (r.relation === 'answers') reasons.push('it answers a named open question');
355
+ if (r.in_scope === false) reasons.push('it is outside the initiative\'s scope');
356
+ if (r.flags && r.flags.length) reasons.push('it carries a flag (' + r.flags.join(', ') + ')');
357
+ if (!reasons.length) continue;
358
+ const ini = byId.get(r.initiative);
359
+ const deep = r.relation === 'contradicts' && accepted.has(r.initiative);
360
+ plan.context.push({ segment: id, initiative: r.initiative, relation: r.relation, reasons, tier: deep ? 'deep' : 'balanced',
361
+ why: deep ? 'a contradiction of an initiative the PM has accepted: a mistake here is costly to find later' : 'a short note from the initiative\'s own text',
362
+ read: [ini && ini.prd, 'planning/initiatives/' + r.initiative + '.md'].filter(Boolean), done: decided.has(id) });
363
+ }
364
+ const drafted = new Set(rows.filter((r) => r.row === 'candidate_draft').map((r) => r.candidate));
365
+ for (const [cid, c] of foldCandidates(rows)) plan.candidates.push({ candidate: cid, segments: c.segments, topic_terms: c.topic_terms, tier: 'deep', why: 'drafting a new initiative\'s title and lane: the work where a mistake is costly', done: drafted.has(cid) });
366
+ return plan;
367
+ }
368
+
369
+ function parseLines(text, tag) {
370
+ const out = []; const bad = [];
371
+ const re = new RegExp('^\\s*' + tag + '\\s+(\\{.*\\})\\s*$');
372
+ for (const line of String(text || '').split(/\r?\n/)) {
373
+ const m = re.exec(line); if (!m) continue;
374
+ try { out.push(JSON.parse(m[1])); } catch { bad.push('not JSON'); }
375
+ }
376
+ return { entries: out, rejected: bad };
377
+ }
378
+ const parseContext = (text) => parseLines(text, 'LIVECONTEXT');
379
+ const parseDrafts = (text) => parseLines(text, 'LIVECANDIDATE');
380
+
381
+ /** A citation must name a real file inside the repository, not excluded, and quote it exactly. */
382
+ function checkCite(root, exclude, c) {
383
+ if (!c || typeof c.path !== 'string' || typeof c.quote !== 'string' || c.quote.trim().length < 8) return 'a citation needs a path and a quotation of at least 8 characters';
384
+ const relp = c.path.replace(/\\/g, '/').replace(/^\/+/, '');
385
+ if (relp.split('/').includes('..')) return 'a citation path may not leave the repository';
386
+ if (isExcluded(exclude, relp)) return 'the cited file is excluded';
387
+ const abs = path.resolve(root, relp);
388
+ if (abs !== root && !abs.startsWith(root + path.sep)) return 'a citation path may not leave the repository';
389
+ let st; try { st = fs.lstatSync(abs); } catch { return 'the cited file does not exist: ' + relp; }
390
+ if (!st.isFile() || st.isSymbolicLink() || st.size > MAX_CITE_FILE) return 'the cited file cannot be read as a plain document: ' + relp;
391
+ const text = fs.readFileSync(abs, 'utf8');
392
+ return text.includes(c.quote) ? null : 'the quotation is not an exact part of ' + relp;
393
+ }
394
+
395
+ /* ── segments in ───────────────────────────────────────────────────────────────────────────────── */
396
+ const PRIVATE_SOURCE = /\b(private|personal|confidential|hr)\b/i;
397
+ function normalizeSegments(raw, { source = 'input', now = Date.now(), prefix = 's' } = {}) {
398
+ return raw.map((r, i) => {
399
+ const o = typeof r === 'string' ? { text: r } : (r || {});
400
+ const text = oneLine(o.text);
401
+ return {
402
+ id: typeof o.id === 'string' && o.id ? o.id : prefix + (i + 1),
403
+ text, at: o.at ? nowISO(Date.parse(o.at) || now) : nowISO(now),
404
+ speaker: o.speaker ? oneLine(o.speaker).slice(0, 60) : null,
405
+ source: oneLine(o.source || source).slice(0, 120),
406
+ private: o.private === true || PRIVATE_SOURCE.test(String(o.source || '')),
407
+ };
408
+ }).filter((s) => s.text.length >= MIN_SEGMENT_CHARS);
409
+ }
410
+
411
+ /** A transcript, with or without `Name:` speaker prefixes, as segments. Deterministic: the same text gives the same ids. */
412
+ const SEG_MAX = 400;
413
+ function segmentsFromTranscript(text, { source = 'transcript', now = Date.now() } = {}) {
414
+ const out = [];
415
+ let speaker = null; let buf = [];
416
+ const flush = () => {
417
+ const body = oneLine(buf.join(' ')); buf = [];
418
+ if (!body) return;
419
+ const sentences = body.split(/(?<=[.!?])\s+/);
420
+ let cur = '';
421
+ const push = (t) => { const x = oneLine(t); if (x.length >= MIN_SEGMENT_CHARS) out.push({ id: 't' + sha(speaker + '|' + x).slice(0, 10), text: x, speaker, source, at: nowISO(now) }); };
422
+ for (const sen of sentences) {
423
+ if (sen.length > SEG_MAX) { if (cur) { push(cur); cur = ''; } for (let i = 0; i < sen.length; i += SEG_MAX) push(sen.slice(i, i + SEG_MAX)); continue; }
424
+ if ((cur + ' ' + sen).trim().length > SEG_MAX) { push(cur); cur = sen; } else cur = (cur + ' ' + sen).trim();
425
+ if (cur.length >= 60) { push(cur); cur = ''; }
426
+ }
427
+ if (cur) push(cur);
428
+ };
429
+ for (const line of String(text || '').split(/\r?\n/)) {
430
+ const m = /^([A-Z][\w .'’-]{0,40}):\s+(.*)$/.exec(line.trim());
431
+ if (m && !/^(https?|ftp)$/i.test(m[1])) { flush(); speaker = oneLine(m[1]); buf.push(m[2]); }
432
+ else if (!line.trim()) { flush(); speaker = null; } // after a blank line the speaker is unknown, not the last one
433
+ else buf.push(line.trim());
434
+ }
435
+ flush();
436
+ // the same text said by the same speaker twice in one transcript stays one segment
437
+ const seen = new Set();
438
+ return out.filter((x) => (seen.has(x.id) ? false : (seen.add(x.id), true)));
439
+ }
440
+
441
+ const quoteOf = (text) => {
442
+ const first = (/^(.*?[.!?])(\s|$)/.exec(text) || [, text])[1];
443
+ const q = first.length > MAX_QUOTE ? first.slice(0, MAX_QUOTE - 1).trimEnd() + '…' : first;
444
+ return q;
445
+ };
446
+
447
+ /* ── sessions ──────────────────────────────────────────────────────────────────────────────────── */
448
+ function sessionPaths({ root, runtimeDir, session }) {
449
+ const dir = path.join(runtimeDir, 'live-notes', session);
450
+ return { dir, transcript: path.join(dir, 'transcript.jsonl'), state: path.join(dir, 'state.json'), ledger: path.join(stateDirIn(root), 'live-notes', session + '.jsonl') };
451
+ }
452
+ const newSessionId = (now) => 'ln-' + nowISO(now).replace(/[-:]/g, '').replace(/\..*/, '').toLowerCase() + '-' + crypto.randomBytes(2).toString('hex');
453
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,60}$/;
454
+
455
+ function readLedger(file) {
456
+ let raw = ''; try { raw = fs.readFileSync(file, 'utf8'); } catch { return []; }
457
+ return raw.split('\n').filter((l) => l.trim()).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
458
+ }
459
+
460
+ /**
461
+ * Open (or resume) a session.
462
+ * @param {object} a
463
+ * @param {string} a.root, a.runtimeDir
464
+ * @param {string} [a.session]
465
+ * @param {string|null} [a.key] the TypeSafe key, or null. Never written anywhere.
466
+ * @param {{consent:boolean, live:boolean}} [a.flags]
467
+ * @param {string[]} [a.exclude]
468
+ * @param {string|null} [a.anchor]
469
+ * @param {Function|null} [a.ask] injected sender. Absent: agent-only, or jev-agent when the gates hold. No command injects one.
470
+ * @param {object} [a.askOpts]
471
+ * @param {Map|null} [a.agentAnswers]
472
+ */
473
+ function openSession({ root, runtimeDir, session = null, key = null, flags = {}, exclude = [], anchor = null, ask = null, askOpts = {}, agentAnswers = null, now = Date.now() }) {
474
+ if (session && !SAFE_ID.test(session)) throw new Error('a session id is letters, digits, dots, dashes and underscores');
475
+ const id = session || newSessionId(now);
476
+ const P = sessionPaths({ root, runtimeDir, session: id });
477
+ fs.mkdirSync(path.dirname(P.ledger), { recursive: true });
478
+ const guard = createSinkGuard({ sink: P.ledger, what: 'live-notes' });
479
+ const append = (row) => guard.appendLine(P.ledger, { ...row, session: id, at: nowISO(row.at) });
480
+ const st0 = readJson(P.state, null);
481
+ const state = st0 || { started: nowISO(now), rejected: 0, unreachable: 0, paused: null, n: 0, candidates: {}, closed: false };
482
+ if (readLedger(P.ledger).some((r) => r.row === 'close')) state.closed = true;
483
+ const g = gates({ key: !!key, consent: !!flags.consent, live: !!flags.live });
484
+ const jevPath = g.open && typeof ask === 'function';
485
+ const route = jevPath ? 'jev' : g.open ? 'jev-agent' : 'agent-only';
486
+ const loaded = loadInitiatives(root);
487
+ const sl = shortlist(loaded.open, { anchor });
488
+ const items = sl.ok ? attachQuestions(root, sl.list, exclude) : [];
489
+ const ids = items.map((i) => i.id);
490
+ const save = () => writeJson(P.state, state);
491
+
492
+ if (!st0 && !readLedger(P.ledger).some((r) => r.row === 'session')) {
493
+ append({ row: 'session', at: now, path: route, gates_missing: g.missing, anchor, shortlist: ids.length, stopped_excluded: loaded.stopped.length, shortlist_error: sl.ok ? null : sl.error });
494
+ }
495
+ if (!st0 && !state.closed) save();
496
+
497
+ async function ask1(segment, pass) {
498
+ const req = requestFor(segment, items, pass);
499
+ const res = await ask({ key, state: req.state, questions: req.questions, ...askOpts });
500
+ return { req, res };
501
+ }
502
+ const failure = (res, at) => {
503
+ if (res.status === 401) { state.rejected++; state.unreachable = 0; } else { state.unreachable++; state.rejected = 0; }
504
+ if (state.rejected >= REJECTED_LIMIT) state.paused = { reason: 'The service rejected the key twice, so Jev is paused for this session. The agent-only path continues.', at: nowISO(at) };
505
+ else if (state.unreachable >= UNREACHABLE_LIMIT) state.paused = { reason: 'The service could not be reached three times in a row, so Jev is paused for this session. The agent-only path continues.', at: nowISO(at) };
506
+ if (state.paused) append({ row: 'pause', at, reason: state.paused.reason });
507
+ };
508
+
509
+ /** Process segments. Returns one result per segment, in order. */
510
+ async function add(raw, opts = {}) {
511
+ const result = { path: null, rows: [], withheld: [], pending: [], failed: 0, prompt: null, paused: state.paused, gates_missing: g.missing };
512
+ if (state.closed) throw new Error('this session is closed');
513
+ if (!sl.ok) { result.error = sl.error; return result; }
514
+ if (!items.length) { result.error = 'There are no open initiatives to place a talking point against.'; return result; }
515
+ const segs = normalizeSegments(raw, { source: opts.source || 'input', now: opts.now || Date.now(), prefix: 's' + (state.n + 1) + '-' });
516
+ const usable = [];
517
+ const known = opts.dedupe ? new Set(readLedger(P.ledger).filter((r) => r.hash).map((r) => r.hash)) : null;
518
+ result.skipped_duplicates = 0;
519
+ for (const s of segs) {
520
+ if (known && known.has(sha(s.text))) { result.skipped_duplicates++; continue; }
521
+ state.n++;
522
+ const hits = scan(s.text);
523
+ if (hits.length || s.private) {
524
+ const reason = hits.length ? 'matched the secret scan (' + hits.join(', ') + ')' : 'the source is marked private';
525
+ append({ row: 'withheld', at: s.at, segment: s.id, hash: sha(s.text), pointer: { kind: 'live', source: s.source, id: s.id }, reason });
526
+ result.withheld.push({ id: s.id, reason });
527
+ continue;
528
+ }
529
+ usable.push(s);
530
+ }
531
+ // the raw words: runtime directory only, never the repository
532
+ if (usable.length) {
533
+ fs.mkdirSync(P.dir, { recursive: true });
534
+ fs.appendFileSync(P.transcript, usable.map((s) => JSON.stringify(s)).join('\n') + '\n');
535
+ }
536
+ const useJev = jevPath && !state.paused;
537
+ result.path = useJev ? 'jev' : route === 'jev-agent' ? 'jev-agent' : 'agent-only';
538
+ const pendingSegs = [];
539
+ for (const s of usable) {
540
+ const pointer = { kind: 'live', source: s.source, id: s.id, at: s.at, speaker: s.speaker };
541
+ let prop = null; let model = null; let by = 'agent';
542
+ if (useJev && !state.paused) {
543
+ by = 'jev';
544
+ let { req, res } = await ask1(s, 0);
545
+ if (!res.ok) { result.failed++; failure(res, Date.now()); pendingSegs.push(s); continue; }
546
+ state.rejected = 0; state.unreachable = 0;
547
+ let m = mapJev(res.answers, req, ids);
548
+ model = res.model || null;
549
+ let inconsistent = false;
550
+ if (m && weak(m.ini)) {
551
+ const again = await ask1(s, 1);
552
+ if (again.res.ok) {
553
+ const m2 = mapJev(again.res.answers, again.req, ids);
554
+ if (m2 && m2.ini.choice !== m.ini.choice) inconsistent = true;
555
+ if (m2 && !inconsistent) m = m2;
556
+ model = again.res.model || model;
557
+ } else { result.failed++; failure(again.res, Date.now()); }
558
+ }
559
+ if (!m) { append({ row: 'unreadable', at: s.at, segment: s.id, pointer }); result.rows.push({ segment: s.id, status: 'unreadable' }); continue; }
560
+ prop = proposalFrom(m, { flags: [], inconsistent });
561
+ } else {
562
+ const a = (opts.agentAnswers || agentAnswers || new Map()).get(s.id);
563
+ if (!a) { pendingSegs.push(s); continue; }
564
+ prop = proposalFromAgent(a);
565
+ }
566
+ const row = { row: 'segment', at: s.at, segment: s.id, hash: sha(s.text), pointer, quote: quoteOf(s.text), path: by === 'jev' ? 'jev' : 'agent-only', model, ...prop, status: 'proposed' };
567
+ append(row);
568
+ result.rows.push(row);
569
+ }
570
+ if (pendingSegs.length) {
571
+ for (const s of pendingSegs) {
572
+ const row = { row: 'segment', at: s.at, segment: s.id, hash: sha(s.text), pointer: { kind: 'live', source: s.source, id: s.id, at: s.at, speaker: s.speaker }, quote: quoteOf(s.text), path: 'agent-only', model: null, initiative: null, relation: null, in_scope: null, confidence: 'unconfirmed', flags: ['unanswered'], status: 'pending' };
573
+ append(row);
574
+ result.rows.push(row);
575
+ result.pending.push(s.id);
576
+ }
577
+ result.prompt = agentPrompt(pendingSegs, items, { jev: g.open && !jevPath });
578
+ }
579
+ groupCandidates();
580
+ result.paused = state.paused;
581
+ save();
582
+ return result;
583
+ }
584
+
585
+ /** Apply an agent's answers to segments still pending: appends a proposed row for each; the pending row stays. */
586
+ function answer(map) {
587
+ const latest = new Map();
588
+ for (const r of readLedger(P.ledger)) if (r.row === 'segment') latest.set(r.segment, r);
589
+ const done = []; const unknown = [];
590
+ for (const [segId, a] of map) {
591
+ const prev = latest.get(segId);
592
+ if (!prev || prev.status !== 'pending') { unknown.push(segId); continue; }
593
+ const prop = proposalFromAgent(a, { jevAllowed: g.open });
594
+ const row = { row: 'segment', at: prev.at, segment: segId, hash: prev.hash, pointer: prev.pointer, quote: prev.quote, path: prop.judged_by === 'jev' ? 'jev-agent' : 'agent-only', model: null, ...prop, status: 'proposed' };
595
+ append(row); done.push(row);
596
+ }
597
+ if (done.length) groupCandidates();
598
+ return { answered: done, unknown };
599
+ }
600
+
601
+ /** Unmatched segments about one topic become one candidate. Never an initiative record. */
602
+ function groupCandidates() {
603
+ const rows = readLedger(P.ledger).filter((r) => r.row === 'segment' && r.status === 'proposed' && r.initiative === 'none');
604
+ let transcript = [];
605
+ try { transcript = fs.readFileSync(P.transcript, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)); } catch { /* none */ }
606
+ const textOf = new Map(transcript.map((s) => [s.id, s.text]));
607
+ const groups = [];
608
+ for (const r of rows) {
609
+ const t = terms(textOf.get(r.segment) || r.quote);
610
+ const g = groups.find((x) => overlap(t, x.terms) >= 2);
611
+ if (g) { g.ids.push(r.segment); for (const w of t) g.terms.add(w); } else groups.push({ ids: [r.segment], terms: new Set(t) });
612
+ }
613
+ const prior = priorRejections(root, exclude);
614
+ for (const g of groups) {
615
+ if (g.ids.length < 2) continue;
616
+ const cid = 'c-' + g.ids[0];
617
+ const sig = g.ids.join(',');
618
+ if (state.candidates[cid] === sig) continue;
619
+ const topic = [...g.terms].slice(0, 8);
620
+ append({ row: 'candidate', candidate: cid, segments: g.ids, topic_terms: topic, status: 'proposed',
621
+ prior_rejection_check: prior.check(topic) });
622
+ state.candidates[cid] = sig;
623
+ }
624
+ }
625
+
626
+ /** Run 3: what an agent should write, on which tier. Recorded in the D75 ledger only where the PM opted in. */
627
+ function plan() {
628
+ const p = contextPlan({ root, rows: readLedger(P.ledger), items });
629
+ const core = modelCore();
630
+ if (core && core.logDecision) {
631
+ for (const x of p.context) if (!x.done) core.logDecision(root, { kind: 'live-notes-plan', step: 'context-note', segment: x.segment, tier: x.tier, by: 'live-notes' });
632
+ for (const x of p.candidates) if (!x.done) core.logDecision(root, { kind: 'live-notes-plan', step: 'candidate-draft', candidate: x.candidate, tier: x.tier, by: 'live-notes' });
633
+ }
634
+ return p;
635
+ }
636
+
637
+ /** Run 3: a context note, checked. The proposal row is never touched; the note is a row of its own. */
638
+ function addContext(entries) {
639
+ const rows = readLedger(P.ledger);
640
+ const planned = new Map(contextPlan({ root, rows, items }).context.map((x) => [x.segment, x]));
641
+ const accepted = []; const rejected = [];
642
+ for (const e of entries) {
643
+ const why = (m) => rejected.push({ segment: e && e.segment, reason: m });
644
+ if (!e || typeof e.segment !== 'string') { why('no segment named'); continue; }
645
+ const x = planned.get(e.segment);
646
+ if (!x) { why('that point needs no note, or does not exist'); continue; }
647
+ if (typeof e.note !== 'string' || !oneLine(e.note) || oneLine(e.note).length > MAX_NOTE) { why('a note is 1 to ' + MAX_NOTE + ' characters'); continue; }
648
+ if (typeof e.settled !== 'boolean') { why('say whether the initiative\'s own text settled the point (settled: true or false)'); continue; }
649
+ if (!Array.isArray(e.cites) || !e.cites.length || e.cites.length > 5) { why('a note cites 1 to 5 passages of the repository'); continue; }
650
+ const bad = e.cites.map((c) => checkCite(root, exclude, c)).find(Boolean);
651
+ if (bad) { why(bad); continue; }
652
+ const row = { row: 'context', at: Date.now(), segment: e.segment, initiative: x.initiative, note: oneLine(e.note), settled: e.settled, tier: x.tier,
653
+ cites: e.cites.map((c) => ({ path: c.path.replace(/\\/g, '/').replace(/^\/+/, ''), quote: oneLine(c.quote).slice(0, 200) })), status: 'proposed' };
654
+ append(row); accepted.push(row);
655
+ }
656
+ return { accepted, rejected };
657
+ }
658
+
659
+ /** Run 3: a title and lane for a candidate. A proposal; an initiative exists only if the PM presses through the router. */
660
+ function addDrafts(entries) {
661
+ const rows = readLedger(P.ledger);
662
+ const cands = foldCandidates(rows);
663
+ const prior = priorRejections(root, exclude);
664
+ const accepted = []; const rejected = [];
665
+ for (const e of entries) {
666
+ const why = (m) => rejected.push({ candidate: e && e.candidate, reason: m });
667
+ if (!e || !cands.has(e.candidate)) { why('no such candidate in this session'); continue; }
668
+ const title = oneLine(e.title);
669
+ if (!title || title.length > MAX_TITLE) { why('a title is 1 to ' + MAX_TITLE + ' characters'); continue; }
670
+ if (!LANES.includes(e.lane)) { why('lane is one of: ' + LANES.join(', ')); continue; }
671
+ const row = { row: 'candidate_draft', at: Date.now(), candidate: e.candidate, title, lane: e.lane, tier: 'deep', status: 'proposed',
672
+ prior_rejection_check: prior.check([...terms(title)]), next_step: 'a proposal only: an initiative exists when the PM presses through pmos-router' };
673
+ append(row); accepted.push(row);
674
+ }
675
+ return { accepted, rejected };
676
+ }
677
+
678
+ /** The PM's accept, change or reject. Appended; never overwrites a proposal. */
679
+ function decide({ segment, action, initiative = null, relation = null, by = 'pm' }) {
680
+ if (!['accept', 'change', 'reject'].includes(action)) throw new Error('action is accept, change or reject');
681
+ const prop = readLedger(P.ledger).filter((r) => r.row === 'segment' && r.segment === segment).pop();
682
+ if (!prop) throw new Error('no such segment in this session: ' + segment);
683
+ if (prop.status !== 'proposed') throw new Error('that segment has no proposal yet, so there is nothing to accept or change');
684
+ if (action === 'change') {
685
+ if (initiative && initiative !== 'none' && !ids.includes(initiative)) throw new Error('that initiative is not in the shortlist');
686
+ if (relation && !RELATIONS.includes(relation)) throw new Error('relation is one of: ' + RELATIONS.join(', '));
687
+ if (!initiative && !relation) throw new Error('a change names an initiative, a relation, or both');
688
+ }
689
+ const row = { row: 'decision', segment, action, by, proposed_by: prop.path === 'jev' || prop.path === 'jev-agent' ? 'jev' : 'agent', was: { initiative: prop.initiative, relation: prop.relation },
690
+ now: action === 'change' ? { initiative: initiative || prop.initiative, relation: relation || prop.relation } : null, overridden: action !== 'accept' };
691
+ append(row);
692
+ return row;
693
+ }
694
+
695
+ /** Close: a summary section in the day's requirements note, then the transcript goes (or is dated to go). */
696
+ function close({ retentionDays = 0, at = Date.now() } = {}) {
697
+ if (state.closed) return { already: true };
698
+ const rows = readLedger(P.ledger);
699
+ const latest = new Map();
700
+ for (const r of rows) if (r.row === 'segment') latest.set(r.segment, r);
701
+ const decisions = new Map();
702
+ for (const r of rows) if (r.row === 'decision') decisions.set(r.segment, r);
703
+ const cands = new Map();
704
+ for (const r of rows) if (r.row === 'candidate') cands.set(r.candidate, r);
705
+ const ctx = new Map(); for (const r of rows) if (r.row === 'context') ctx.set(r.segment, r);
706
+ const drafts = new Map(); for (const r of rows) if (r.row === 'candidate_draft') drafts.set(r.candidate, r);
707
+ const note = writeNote({ root, session: id, at, items: [...latest.values()], decisions, ctx, drafts, candidates: [...cands.values()], withheld: rows.filter((r) => r.row === 'withheld').length, path: route, paused: state.paused });
708
+ append({ row: 'close', at, segments: latest.size, note: note.rel, transcript_kept_until: retentionDays > 0 ? nowISO(at + retentionDays * 86400000) : null });
709
+ state.closed = true;
710
+ if (retentionDays > 0) writeJson(path.join(P.dir, 'expires.json'), { at: nowISO(at + retentionDays * 86400000) });
711
+ else { try { fs.rmSync(P.dir, { recursive: true, force: true }); } catch { /* reported by the test */ } return { note: note.rel, transcript: 'deleted' }; }
712
+ save();
713
+ return { note: note.rel, transcript: 'kept until ' + nowISO(at + retentionDays * 86400000) };
714
+ }
715
+
716
+ return { id, paths: P, items, shortlistError: sl.ok ? null : sl.error, path: route, gates: g, add, answer, plan, addContext, addDrafts, decide, close, ledger: () => readLedger(P.ledger), state: () => ({ ...state }) };
717
+ }
718
+
719
+ /** Delete transcripts whose retention has passed. */
720
+ function purgeExpired(runtimeDir, now = Date.now()) {
721
+ const base = path.join(runtimeDir, 'live-notes');
722
+ let n = 0;
723
+ let entries = []; try { entries = fs.readdirSync(base, { withFileTypes: true }); } catch { return 0; }
724
+ for (const e of entries) {
725
+ if (!e.isDirectory()) continue;
726
+ const x = readJson(path.join(base, e.name, 'expires.json'), null);
727
+ if (x && Date.parse(x.at) <= now) { fs.rmSync(path.join(base, e.name), { recursive: true, force: true }); n++; }
728
+ }
729
+ return n;
730
+ }
731
+
732
+ /** The prior-rejection check the router makes, against planning/out-of-scope/. A hit is information, never a veto. */
733
+ function priorRejections(root, exclude) {
734
+ const dir = path.join(root, 'planning', 'out-of-scope');
735
+ let files = [];
736
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.md') && !/^(README|index|log)\.md$/i.test(f)); } catch { /* none */ }
737
+ const docs = [];
738
+ for (const f of files) {
739
+ const r = 'planning/out-of-scope/' + f;
740
+ if (isExcluded(exclude, r)) continue;
741
+ try { const t = fs.readFileSync(path.join(dir, f), 'utf8'); docs.push({ path: r, title: frontmatter(t).title || f, terms: terms(t) }); } catch { /* skip */ }
742
+ }
743
+ return {
744
+ check: (topic) => {
745
+ const topicSet = new Set(topic);
746
+ const matches = docs.map((d) => ({ path: d.path, title: d.title, shared: overlap(topicSet, d.terms) })).filter((m) => m.shared >= 2).sort((a, b) => b.shared - a.shared).slice(0, 3);
747
+ return { ran: true, source: 'planning/out-of-scope', documents_read: docs.length, found: matches.length, matches };
748
+ },
749
+ };
750
+ }
751
+
752
+ /* ── the day's requirements note (D71's shape; append, never overwrite) ──────────────────────────── */
753
+ function writeNote({ root, session, at, items, decisions, ctx = new Map(), drafts = new Map(), candidates, withheld, path: pathName, paused }) {
754
+ const day = nowISO(at).slice(0, 10); const hm = nowISO(at).slice(11, 16);
755
+ const dir = path.join(stateDirIn(root), 'requirements');
756
+ const file = path.join(dir, day + '.md');
757
+ const word = { contradicts: 'contradicts', answers: 'answers an open question of', validates: 'validates', clarifies: 'clarifies', extends: 'extends' };
758
+ const line = (r) => {
759
+ const dec = decisions.get(r.segment);
760
+ const where = r.initiative && r.initiative !== 'none' ? `${word[r.relation] || r.relation} \`${r.initiative}\` (${r.relation_band})` : 'matches no initiative';
761
+ const tail = dec ? ` — PM: ${dec.action}${dec.now ? ' → ' + [dec.now.initiative, dec.now.relation].filter(Boolean).join(' / ') : ''}` : '';
762
+ const c = ctx.get(r.segment);
763
+ const more = c ? `\n - context (${c.tier}; ${c.settled ? 'the initiative\'s text settles it' : 'the initiative\'s text does not settle it'}): ${c.note} [${c.cites.map((x) => x.path).join(', ')}]` : '';
764
+ return `- "${r.quote}" — ${where}; ${r.confidence}${r.in_scope === false ? '; outside scope' : ''}; pointer: live · ${r.pointer.source} · ${r.pointer.id} · ${r.at}${tail}${more}`;
765
+ };
766
+ const answered = items.filter((r) => r.status === 'proposed');
767
+ const body = [];
768
+ body.push(`## Live notes ${hm} (session ${session})`, '');
769
+ body.push(`Proposals only: nothing here is a decision. Path: ${pathName}.${paused ? ' ' + paused.reason : ''}`, '');
770
+ body.push('### New or changed requirements', ...(answered.filter((r) => ['extends', 'clarifies', 'none'].includes(r.relation)).map(line) || []), '');
771
+ body.push('### Validations and contradictions', ...answered.filter((r) => ['validates', 'contradicts'].includes(r.relation)).sort((a, b) => (a.relation === 'contradicts' ? -1 : 1) - (b.relation === 'contradicts' ? -1 : 1)).map(line), '');
772
+ body.push('### Open questions', ...answered.filter((r) => r.relation === 'answers').map((r) => line(r) + (r.answers_question ? `; answers: ${r.answers_question.text}` : '')),
773
+ ...candidates.map((c) => { const d = drafts.get(c.candidate); return `- Possible new initiative (${c.segments.length} points about: ${c.topic_terms.join(', ')}); prior-rejection check found ${c.prior_rejection_check.found}.${d ? ` Draft: "${d.title}" (${d.lane} lane; prior-rejection check on the title found ${d.prior_rejection_check.found}). A proposal; the PM presses through pmos-router to create it.` : ''}`; }), '');
774
+ body.push('### Sources reached and not reached', `- live conversation: ${items.length} point(s) proposed, ${items.filter((r) => r.status === 'pending').length} still unanswered, ${withheld} withheld (secret-shaped or private-marked, not read further).`, '');
775
+ fs.mkdirSync(dir, { recursive: true });
776
+ let exists = false;
777
+ try { const ls = fs.lstatSync(file); if (ls.isSymbolicLink() || ls.nlink > 1) throw new Error('the requirements note is a link, so nothing was written'); exists = true; } catch (e) { if (e.code !== 'ENOENT') throw e; }
778
+ if (exists) {
779
+ fs.appendFileSync(file, '\n' + body.join('\n'));
780
+ } else {
781
+ const head = ['---', 'type: Reference', `title: "Requirement note ${day}"`, 'description: "Requirements found in the PM\'s sources since the previous note."',
782
+ `generated: {by: pmos/live-notes, at: "${nowISO(at)}"}`, `sources: [{resource: "live-notes/${session}", title: "live conversation"}]`, 'status: draft', '---', '', `# Requirement note ${day}`, '', ''];
783
+ fs.writeFileSync(file, head.join('\n') + body.join('\n'));
784
+ }
785
+ return { rel: rel(root, file) };
786
+ }
787
+
788
+
789
+ /* ── Run 2: the ranked list (N12) and the two routes' logic, without a page ──────────────────────── */
790
+ const BAND_RANK = { 'very likely': 3, likely: 2, possible: 1, unlikely: 0 };
791
+ const WEAK_BANDS = new Set(['possible', 'unlikely']);
792
+ const SECTION_TITLES = {
793
+ contradictions: 'Contradictions', answers: 'Answers an open question', confirmations: 'Confirmed or validated', rest: 'Other points',
794
+ needs_read: 'Needs your read', unmatched: 'Matches no initiative', pending: 'Not yet answered',
795
+ };
796
+ const SECTION_ORDER = ['contradictions', 'answers', 'confirmations', 'rest', 'needs_read', 'unmatched', 'pending'];
797
+
798
+ function rank(rows) {
799
+ const segs = foldSegments(rows);
800
+ const decisions = new Map(); for (const r of rows) if (r.row === 'decision') decisions.set(r.segment, r);
801
+ const ctx = new Map(); for (const r of rows) if (r.row === 'context') ctx.set(r.segment, r);
802
+ const drafts = new Map(); for (const r of rows) if (r.row === 'candidate_draft') drafts.set(r.candidate, r);
803
+ const sections = Object.fromEntries(SECTION_ORDER.map((k) => [k, []]));
804
+ for (const [id, r] of segs) {
805
+ const d = decisions.get(id); const c = ctx.get(id);
806
+ const item = { segment: id, quote: r.quote, status: r.status, initiative: r.initiative, relation: r.relation, relation_band: r.relation_band || null, initiative_band: r.initiative_band || null,
807
+ in_scope: r.in_scope, confidence: r.confidence, flags: r.flags || [], answers_question: r.answers_question || null, path: r.path, at: r.at,
808
+ decision: d ? { action: d.action, now: d.now, overridden: d.overridden } : null,
809
+ context: c ? { note: c.note, settled: c.settled, tier: c.tier, cites: c.cites.map((x) => x.path) } : null };
810
+ let key;
811
+ if (r.status === 'pending') key = 'pending';
812
+ else if (!r.initiative || r.initiative === 'none') key = 'unmatched';
813
+ else if ((r.flags && r.flags.length) || WEAK_BANDS.has(r.relation_band) || WEAK_BANDS.has(r.initiative_band)) key = 'needs_read';
814
+ else if (r.relation === 'contradicts') key = 'contradictions';
815
+ else if (r.relation === 'answers') key = 'answers';
816
+ else if (r.confidence === 'confirmed' || r.relation === 'validates') key = 'confirmations';
817
+ else key = 'rest';
818
+ sections[key].push(item);
819
+ }
820
+ const score = (x) => (BAND_RANK[x.relation_band] != null ? BAND_RANK[x.relation_band] : -1) * 4 + (BAND_RANK[x.initiative_band] != null ? BAND_RANK[x.initiative_band] : -1);
821
+ for (const k of SECTION_ORDER) sections[k].sort((a, b) => score(b) - score(a) || (a.at < b.at ? -1 : a.at > b.at ? 1 : 0) || (a.segment < b.segment ? -1 : 1));
822
+ const paused = rows.filter((r) => r.row === 'pause').pop() || null;
823
+ const cands = [...foldCandidates(rows).values()].map((c) => { const d = drafts.get(c.candidate); return { candidate: c.candidate, segments: c.segments, topic_terms: c.topic_terms, prior_rejection_found: c.prior_rejection_check ? c.prior_rejection_check.found : null, draft: d ? { title: d.title, lane: d.lane } : null }; });
824
+ return {
825
+ sections: SECTION_ORDER.map((k) => ({ key: k, title: SECTION_TITLES[k], items: sections[k] })).filter((x) => x.items.length),
826
+ candidates: cands,
827
+ withheld: rows.filter((r) => r.row === 'withheld').length, // a count only: the text is never shown
828
+ paused: paused ? { reason: paused.reason, at: paused.at } : null,
829
+ path: (rows.find((r) => r.row === 'session') || {}).path || null,
830
+ closed: rows.some((r) => r.row === 'close'),
831
+ counts: { points: segs.size, pending: sections.pending.length },
832
+ };
833
+ }
834
+
835
+ /** The repository's sessions, from its ledgers. Reads only. */
836
+ function sessionsOf(root) {
837
+ const dir = path.join(stateDirIn(root), 'live-notes');
838
+ let files = []; try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')).sort(); } catch { return []; }
839
+ return files.map((f) => {
840
+ const rows = readLedger(path.join(dir, f));
841
+ const segs = foldSegments(rows);
842
+ const first = rows.find((r) => r.row === 'session');
843
+ const pause = rows.filter((r) => r.row === 'pause').pop();
844
+ return { session: f.replace(/\.jsonl$/, ''), started: first ? first.at : null, path: first ? first.path : null, closed: rows.some((r) => r.row === 'close'),
845
+ points: segs.size, pending: [...segs.values()].filter((r) => r.status === 'pending').length, decided: rows.filter((r) => r.row === 'decision').length,
846
+ withheld: rows.filter((r) => r.row === 'withheld').length, paused: pause ? pause.reason : null };
847
+ });
848
+ }
849
+ const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,60}$/;
850
+ /** GET: the sessions, or one session ranked. Reads only; writes nothing. */
851
+ function apiList({ root, session = null }) {
852
+ if (!root) return { ok: false, status: 400, error: 'no repository' };
853
+ if (!session) return { ok: true, sessions: sessionsOf(root), note: 'Proposals only. Nothing here is a decision.' };
854
+ if (!SESSION_RE.test(session)) return { ok: false, status: 400, error: 'that is not a session id' };
855
+ const file = path.join(stateDirIn(root), 'live-notes', session + '.jsonl');
856
+ if (!fs.existsSync(file)) return { ok: false, status: 404, error: 'no such session in this repository' };
857
+ return { ok: true, session, ranked: rank(readLedger(file)), note: 'Proposals only. Nothing here is a decision.' };
858
+ }
859
+ /** POST: append the PM's accept, change or reject. Nothing else is written. */
860
+ function apiDecide({ root, runtimeDir, session, segment, action, initiative = null, relation = null, exclude = [] }) {
861
+ if (!root) return { ok: false, status: 400, error: 'no repository' };
862
+ if (!SESSION_RE.test(String(session || ''))) return { ok: false, status: 400, error: 'that is not a session id' };
863
+ if (!fs.existsSync(path.join(stateDirIn(root), 'live-notes', session + '.jsonl'))) return { ok: false, status: 404, error: 'no such session in this repository' };
864
+ try {
865
+ const s = openSession({ root, runtimeDir, session, key: null, flags: {}, exclude, ask: null });
866
+ const row = s.decide({ segment, action, initiative, relation, by: 'pm' });
867
+ return { ok: true, decision: row };
868
+ } catch (e) { return { ok: false, status: 400, error: String((e && e.message) || e) }; }
869
+ }
870
+
871
+ /* ── the override rate (N13): the first measure, never an accuracy claim ─────────────────────────── */
872
+ function overrideRate(rows) {
873
+ const by = {};
874
+ const seg = new Map();
875
+ for (const r of rows) if (r.row === 'segment' && r.status === 'proposed') seg.set(r.segment, r);
876
+ const dec = new Map();
877
+ for (const r of rows) if (r.row === 'decision') dec.set(r.segment, r);
878
+ for (const [id, s] of seg) {
879
+ const p = s.path === 'jev' || s.path === 'jev-agent' ? s.path : 'agent-only';
880
+ by[p] = by[p] || { proposed: 0, decided: 0, overridden: 0 };
881
+ by[p].proposed++;
882
+ const d = dec.get(id);
883
+ if (d) { by[p].decided++; if (d.overridden) by[p].overridden++; }
884
+ }
885
+ for (const p of Object.keys(by)) by[p].override_rate = by[p].decided ? by[p].overridden / by[p].decided : null;
886
+ return by;
887
+ }
888
+
889
+ module.exports = { rank, sessionsOf, apiList, apiDecide, segmentsFromTranscript, contextPlan, parseContext, parseDrafts, checkCite, LANES, openSession, purgeExpired, getSwitch, setSwitch, gates, gateSentence, band, shortlist, loadInitiatives, openQuestionsOf, requestFor,
890
+ mapJev, parseAgentAnswers, agentPrompt, normalizeSegments, overrideRate, readLedger, sessionPaths, stateDirIn, isExcluded, RELATIONS, MAX_CHOICE, REJECTED_LIMIT, UNREACHABLE_LIMIT };