create-pmos 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  3. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
  4. package/payload/mods/pmos-cockpit/README.md +10 -1
  5. package/payload/mods/pmos-cockpit/hooks/lib/route.mjs +95 -0
  6. package/payload/mods/pmos-cockpit/hooks/register.tsx +67 -1
  7. package/payload/mods/pmos-cockpit/types/index.d.ts +1 -1
  8. package/payload/planning/evals/eval-calibration.md +1 -1
  9. package/payload/planning/okrs/health-budgets.md +1 -1
  10. package/payload/scripts/status-facts.js +13 -3
  11. package/payload/scripts/workflow-state.js +7 -2
  12. package/payload/state/discoveries.md +1 -1
  13. package/payload/state/log.md +1 -1
  14. package/workspace/guide/index.md +2 -1
  15. package/workspace/guide/initiatives-and-next-steps.md +3 -1
  16. package/workspace/guide/knowledge-graph.md +12 -1
  17. package/workspace/guide/layout.md +2 -0
  18. package/workspace/guide/live-notes.md +78 -0
  19. package/workspace/guide/validate-with-typesafe.md +15 -1
  20. package/workspace/lib/engine/status-facts.js +13 -3
  21. package/workspace/lib/engine/workflow-state.js +7 -2
  22. package/workspace/lib/jev-categorize.js +47 -13
  23. package/workspace/lib/jev-live.js +37 -0
  24. package/workspace/lib/jev-route.js +11 -1
  25. package/workspace/lib/live-notes.js +171 -26
  26. package/workspace/package-lock.json +1 -1
  27. package/workspace/package.json +2 -2
  28. package/workspace/public/pm/data.js +21 -1
  29. package/workspace/public/pm/modes-live.js +68 -5
  30. package/workspace/public/pm/panel-cockpit.js +3 -1
  31. package/workspace/public/pm/panel-detail.js +3 -1
  32. package/workspace/public/pm/panel-tabs.js +112 -10
  33. package/workspace/public/pm/state.js +3 -0
  34. package/workspace/public/pm/view-livenotes.js +397 -0
  35. package/workspace/server.js +66 -7
@@ -17,6 +17,10 @@
17
17
  * WHAT A RESULT IS. Advisory (D40), basis `jev`, shown in words. The ticket options are summed into one `ticket` first;
18
18
  * a top category under 0.6, or within 0.1 of the next, is `undetermined` with the two leading options named (C6).
19
19
  *
20
+ * WHAT THE PAGE SEES. Each pass records the outcome of every undetermined file it looked at: categorized, unsure,
21
+ * unreadable, failed (with the service's reason), withheld, excluded, or not asked because the pass paused. The record holds
22
+ * paths and outcomes, never a snippet or the key, and keeps at most MAX_FILES files.
23
+ *
20
24
  * WHAT IT NEVER DOES. It writes nothing into the repository. Its state (confirmation, pause, cache) lives in the per-user
21
25
  * cache directory the caller passes. Two rejected keys or three failed asks in a row pause the repository until resumed.
22
26
  */
@@ -38,6 +42,7 @@ const UNREACHABLE_LIMIT = 3;
38
42
  const UNSURE_BELOW = 0.6;
39
43
  const UNSURE_GAP = 0.1;
40
44
  const UNREADABLE_LIMIT = 3;
45
+ const MAX_FILES = 500;
41
46
  const sha = (s) => crypto.createHash('sha1').update(s).digest('hex');
42
47
 
43
48
  /* The Choice options. A ticket is three options, so one question decides the category and the ticket kind. */
@@ -183,7 +188,7 @@ async function locked(cacheDir, busy, fn) {
183
188
  try { return await fn(); } finally { dropLock(cacheDir); }
184
189
  }
185
190
 
186
- const emptyState = () => ({ confirmed: false, confirmed_at: null, paused: null, rejected: 0, unreachable: 0, unreadable: 0, last: null });
191
+ const emptyState = () => ({ confirmed: false, confirmed_at: null, paused: null, rejected: 0, unreachable: 0, unreadable: 0, last: null, results: null });
187
192
  function readState(cacheDir) {
188
193
  const s = readJson(stateFile(cacheDir), null);
189
194
  return s && typeof s === 'object' ? { ...emptyState(), ...s } : emptyState();
@@ -199,7 +204,7 @@ function writeCache(cacheDir, c) {
199
204
  /** What the page may know about the bridge for one repository. */
200
205
  function status(cacheDir) {
201
206
  const s = readState(cacheDir);
202
- return { confirmed: s.confirmed, confirmed_at: s.confirmed_at, paused: s.paused, last: s.last };
207
+ return { confirmed: s.confirmed, confirmed_at: s.confirmed_at, paused: s.paused, last: s.last, results: s.results || null };
203
208
  }
204
209
  /** The PM's press: lift a pause and forget the failure counts. */
205
210
  function resume(cacheDir) {
@@ -218,14 +223,14 @@ function lookup(cache, doc) {
218
223
 
219
224
  /** Triage the candidates: excluded, withheld by the secret scan, answered from the cache, or to ask. Pure; sends nothing. */
220
225
  function triage(cands, { exclude = [], cache }) {
221
- const out = { ask: [], cached: 0, withheld: [], excluded: 0 };
226
+ const out = { ask: [], cached: 0, withheld: [], excluded: 0, cachedItems: [], excludedRels: [] };
222
227
  for (const d of cands) {
223
- if (settings.isExcluded(exclude, d.rel)) { out.excluded++; continue; }
228
+ if (settings.isExcluded(exclude, d.rel)) { out.excluded++; out.excludedRels.push(d.rel); continue; }
224
229
  const snip = snippetFor(d);
225
230
  const rules = scanner.scan(snip);
226
231
  if (rules.length) { out.withheld.push({ doc: d.rel, rules }); continue; }
227
232
  const ck = keyFor(snip);
228
- if (cache[ck]) out.cached++; else out.ask.push({ rel: d.rel, snip, ck });
233
+ if (cache[ck]) { out.cached++; out.cachedItems.push({ rel: d.rel, ck }); } else out.ask.push({ rel: d.rel, snip, ck });
229
234
  }
230
235
  return out;
231
236
  }
@@ -233,15 +238,15 @@ function triage(cands, { exclude = [], cache }) {
233
238
  /* ── asking ────────────────────────────────────────────────────────────────────────────────────── */
234
239
  async function askAll({ items, key, ask, askOpts, state, cache, now }) {
235
240
  let failed = 0, unreadable = 0, sent = 0, decided = 0, unsure = 0, lastError = null;
236
- const asked = new Set();
241
+ const outcomes = new Map(); // ck → what happened to that snippet
237
242
  for (const it of items) {
238
243
  if (state.paused) break;
239
- if (asked.has(it.ck)) continue; // two files with one snippet: one request
240
- asked.add(it.ck);
244
+ if (outcomes.has(it.ck)) continue; // two files with one snippet: one request
241
245
  const req = requestFor(it.snip);
242
246
  const res = await ask({ key, state: req.state, questions: req.questions, ...askOpts });
243
247
  if (!res.ok) {
244
248
  failed++; lastError = res.reason;
249
+ outcomes.set(it.ck, { outcome: 'failed', reason: String(res.reason || 'the request failed') });
245
250
  if (res.status === 401) { state.rejected++; state.unreachable = 0; } else { state.unreachable++; state.rejected = 0; }
246
251
  if (state.rejected >= REJECTED_LIMIT) state.paused = { reason: 'The service rejected the key twice, so categorizing with Jev is paused.', at: new Date(now).toISOString() };
247
252
  else if (state.unreachable >= UNREACHABLE_LIMIT) state.paused = { reason: 'The service could not be reached three times in a row, so categorizing with Jev is paused.', at: new Date(now).toISOString() };
@@ -253,16 +258,43 @@ async function askAll({ items, key, ask, askOpts, state, cache, now }) {
253
258
  // Cached as a marker, so no later pass sends the same snippet again; three in a row pause the repository, because
254
259
  // that is what a changed answer format looks like.
255
260
  unreadable++; state.unreadable = (state.unreadable || 0) + 1;
261
+ outcomes.set(it.ck, { outcome: 'unreadable' });
256
262
  cache[it.ck] = { probs: null, unreadable: true, model: res.model || null, at: new Date(now).toISOString() };
257
263
  if (state.unreadable >= UNREADABLE_LIMIT) state.paused = { reason: 'Jev\'s answers could not be read three times in a row, so categorizing with Jev is paused.', at: new Date(now).toISOString() };
258
264
  continue;
259
265
  }
260
266
  state.unreadable = 0;
261
267
  cache[it.ck] = { probs, model: res.model || null, at: new Date(now).toISOString() };
262
- if (decide(probs).decided) decided++; else unsure++;
268
+ const d = decide(probs);
269
+ if (d.decided) decided++; else unsure++;
270
+ outcomes.set(it.ck, outcomeOf(d));
271
+ }
272
+ return { failed, unreadable, sent, decided, unsure, lastError, outcomes };
273
+ }
274
+
275
+ /* ── what happened to each file ────────────────────────────────────────────────────────────────── */
276
+ function outcomeOf(d) {
277
+ if (!d) return { outcome: 'unreadable' };
278
+ if (!d.decided) return { outcome: 'unsure', leading: d.leading.map((x) => x.category), sentence: sentenceFor(d) };
279
+ return { outcome: 'categorized', category: d.category, ticket_kind: d.ticket_kind, sentence: sentenceFor(d) };
280
+ }
281
+ /** One entry per undetermined file the pass looked at, in path order. Paths and outcomes only, never a snippet. Pure. */
282
+ function fileResults(t, outcomes, cache, paused) {
283
+ const files = [];
284
+ for (const it of t.ask) {
285
+ const o = outcomes.get(it.ck);
286
+ files.push({ doc: it.rel, ...(o || { outcome: 'not-asked', reason: paused ? paused.reason : 'the pass stopped before this file' }) });
287
+ }
288
+ for (const it of t.cachedItems) {
289
+ const hit = cache[it.ck];
290
+ files.push({ doc: it.rel, ...(hit && hit.probs ? outcomeOf(decide(hit.probs)) : { outcome: 'unreadable' }), cached: true });
263
291
  }
264
- return { failed, unreadable, sent, decided, unsure, lastError };
292
+ for (const w of t.withheld) files.push({ doc: w.doc, outcome: 'withheld', rules: w.rules });
293
+ for (const rel of t.excludedRels) files.push({ doc: rel, outcome: 'excluded' });
294
+ files.sort((a, b) => a.doc.localeCompare(b.doc));
295
+ return { total: files.length, files: files.slice(0, MAX_FILES) };
265
296
  }
297
+ const recordFiles = (state, t, r, cache, now, kind) => { state.results = { at: new Date(now).toISOString(), kind, ...fileResults(t, r.outcomes, cache, state.paused) }; };
266
298
 
267
299
  const summarize = (extra) => ({ at: new Date().toISOString(), sent: 0, cached: 0, withheld: [], excluded: 0, failed: 0, decided: 0, unsure: 0, ...extra });
268
300
  const gatesFor = (key, flags) => gates({ key: typesafe.validKey(key), consent: !!(flags && flags.consent), categorize: !!(flags && flags.categorize) });
@@ -302,9 +334,10 @@ async function confirmedPass({ docs, named, cacheDir, key, exclude = [], expectR
302
334
  }
303
335
  const r = await askAll({ items: t.ask, key, ask, askOpts, state, cache, now });
304
336
  state.confirmed = true; state.confirmed_at = state.confirmed_at || new Date(now).toISOString();
337
+ recordFiles(state, t, r, cache, now, 'confirmed');
305
338
  state.last = summarize({ confirmed: true, sent: r.sent, cached: t.cached, withheld: t.withheld.slice(0, MAX_LISTED), withheld_count: t.withheld.length, excluded: t.excluded, failed: r.failed, unreadable: r.unreadable, decided: r.decided, unsure: r.unsure, error: r.lastError, paused: state.paused });
306
339
  writeCache(cacheDir, cache); writeState(cacheDir, state);
307
- return { ok: true, sent: r.sent, cached: t.cached, withheld: t.withheld.length, excluded: t.excluded, failed: r.failed, unreadable: r.unreadable, decided: r.decided, unsure: r.unsure, paused: state.paused };
340
+ return { ok: true, sent: r.sent, cached: t.cached, withheld: t.withheld.length, excluded: t.excluded, failed: r.failed, unreadable: r.unreadable, decided: r.decided, unsure: r.unsure, paused: state.paused, results: state.results };
308
341
  }
309
342
 
310
343
  /**
@@ -330,6 +363,7 @@ async function autoPass({ docs, named, cacheDir, key, exclude = [], ask = typesa
330
363
  return { ok: true, skipped: 'needs-confirmation', requests: unique, sent: 0 };
331
364
  }
332
365
  const r = await askAll({ items: t.ask, key, ask, askOpts, state, cache, now });
366
+ recordFiles(state, t, r, cache, now, 'automatic');
333
367
  state.last = summarize({ sent: r.sent, cached: t.cached, withheld: t.withheld.slice(0, MAX_LISTED), withheld_count: t.withheld.length, excluded: t.excluded, failed: r.failed, unreadable: r.unreadable, decided: r.decided, unsure: r.unsure, error: r.lastError, paused: state.paused });
334
368
  writeCache(cacheDir, cache); writeState(cacheDir, state);
335
369
  return { ok: true, sent: r.sent, cached: t.cached, failed: r.failed, decided: r.decided, unsure: r.unsure, paused: state.paused };
@@ -394,6 +428,6 @@ async function measuredPass({ rows, cacheDir, key, exclude = [], expectRequests,
394
428
 
395
429
  module.exports = {
396
430
  plan, runConfirmed, runAuto, measurePlan, measure, status, resume, lookup, readCache, gates, gateSentence, candidates, snippetFor, requestFor,
397
- readAnswer, decide, sentenceFor, wordsFor, keyFor, triage,
398
- OPTIONS, SNIPPET_MAX, AUTO_LIMIT, REJECTED_LIMIT, UNREACHABLE_LIMIT, UNREADABLE_LIMIT, UNSURE_BELOW, UNSURE_GAP,
431
+ readAnswer, decide, sentenceFor, wordsFor, keyFor, triage, fileResults,
432
+ OPTIONS, MAX_FILES, SNIPPET_MAX, AUTO_LIMIT, REJECTED_LIMIT, UNREACHABLE_LIMIT, UNREADABLE_LIMIT, UNSURE_BELOW, UNSURE_GAP,
399
433
  };
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+ /* jev-live.js — the one bridge from the Live notes view to Jev (D85, jev-live-notes Run 7).
3
+ *
4
+ * It hands lib/live-notes.js a sender that calls lib/typesafe.js, and only when all three gates hold: a saved key, D79 consent
5
+ * for the repository, and the per-repository switch "live notes may use Jev" (default off, kept in the runtime directory).
6
+ * The engine decides what goes out (one cleaned point and the shortlist) and never sends a withheld point or a point from a
7
+ * private session; this module adds no text of its own. With any gate shut it hands back no sender, and the local word
8
+ * match answers.
9
+ */
10
+ const typesafe = require('./typesafe');
11
+ const live = require('./live-notes');
12
+
13
+ /** The gates, from what the server knows. `key` is the saved key or null; it is never logged or returned. */
14
+ function gatesFor(key, flags) {
15
+ return live.gates({ key: typesafe.validKey(key), consent: !!(flags && flags.consent), live: !!(flags && flags.live) });
16
+ }
17
+
18
+ /* A meeting does not wait: a point Jev cannot answer quickly gets the local guess instead (one retry, ten seconds). */
19
+ const LIVE_ASK = { timeoutMs: 10000, retries: 1, backoffMs: 400 };
20
+
21
+ /** { ask, gates, askOpts }: `ask` is a sender for the engine, or null when a gate is shut. */
22
+ function senderFor(key, flags, askOpts = {}) {
23
+ const g = gatesFor(key, flags);
24
+ if (!g.open) return { ask: null, gates: g, askOpts: {} };
25
+ return { ask: (a) => typesafe.ask(a), gates: g, askOpts: { ...LIVE_ASK, ...askOpts } };
26
+ }
27
+
28
+ const SENT = 'Jev checks each point. Only the cleaned sentence and the titles, intents and open questions of the shortlisted initiatives are sent to TypeSafe. Speaker names, withheld points and private meetings are never sent.';
29
+ const LOCAL = 'Proposals here are a local word match. Nothing leaves this machine.';
30
+
31
+ /** What the view needs to explain the switch. `present` is whether a key is saved (a boolean, never the key). */
32
+ function infoFor(present, flags) {
33
+ const g = live.gates({ key: !!present, consent: !!(flags && flags.consent), live: !!(flags && flags.live) });
34
+ return { switch: !!(flags && flags.live), open: g.open, missing: g.missing, sentence: g.open ? null : live.gateSentence(g.missing), note: g.open ? SENT : LOCAL };
35
+ }
36
+
37
+ module.exports = { gatesFor, senderFor, infoFor };
@@ -126,4 +126,14 @@ function modelFromPolicy(root) {
126
126
  return loaded.ok && loaded.policy.router && typeof loaded.policy.router.jev_model === 'string' ? loaded.policy.router.jev_model : null;
127
127
  }
128
128
 
129
- module.exports = { gates, gateSentence, classify, routePrompt, modelFromPolicy, QUESTIONS };
129
+ /* A prompt routed from a terminal waits for its answer before it runs, so Jev gets one short try:
130
+ * no retries, and a few seconds. A miss falls back to the keyword route, which never moves the model. */
131
+ const INTERACTIVE = Object.freeze({ timeoutMs: 2500, retries: 0 });
132
+
133
+ /** The loopback address the workspace puts in each terminal's environment (PMOS_ROUTE_URL). */
134
+ function routeUrl(port) {
135
+ const n = Number(port);
136
+ return Number.isInteger(n) && n > 0 && n < 65536 ? `http://127.0.0.1:${n}/api/model/route` : null;
137
+ }
138
+
139
+ module.exports = { gates, gateSentence, classify, routePrompt, modelFromPolicy, routeUrl, INTERACTIVE, QUESTIONS };
@@ -12,6 +12,8 @@
12
12
  * Jev" switch — and no sender is injected, the same prompt asks the PM's own agent session to make the two choices
13
13
  * with the Typesafe skill (the route `judgment.js` already uses) and to mark each answer `"by":"jev"`. The
14
14
  * workspace sends nothing itself; no new server egress, so D79 is not amended.
15
+ * local (Run 6): the cockpit's live-notes view. A word match made here, banded no higher than "possible" and flagged
16
+ * `local-guess`, so the PM has something to confirm the moment a point arrives. No model, no Jev, no request.
15
17
  * jev: the same three gates, through the `ask` function a caller injects. This file requires no network module,
16
18
  * and no command injects one: a server-side send of live talk needs a decision that amends D79.
17
19
  *
@@ -194,6 +196,27 @@ function attachQuestions(root, items, exclude) {
194
196
  const terms = (s) => new Set(String(s).toLowerCase().match(/[a-z][a-z0-9-]{3,}/g)?.filter((w) => !STOP.has(w)) || []);
195
197
  const overlap = (a, b) => { let n = 0; for (const t of a) if (b.has(t)) n++; return n; };
196
198
 
199
+ /* ── Run 6: a local word match, for the view. No model and no Jev, so it is never claimed above "possible". ─────── */
200
+ const LOCAL_MIN = 2; // shared words before a local guess names an initiative or an open question
201
+ function localProposal(text, items) {
202
+ const t = terms(text);
203
+ let best = null; let second = 0;
204
+ for (const i of items) {
205
+ const n = overlap(t, terms([i.title, i.intent, ...(i.questions || [])].join(' ')));
206
+ if (!best || n > best.n) { second = best ? best.n : 0; best = { i, n }; } else if (n > second) second = n;
207
+ }
208
+ const flags = ['local-guess'];
209
+ const none = { initiative: 'none', initiative_band: 'unlikely', relation: 'none', relation_band: 'unlikely', in_scope: null, scope_band: null,
210
+ confidence: 'unconfirmed', answers_question: null, flags, judged_by: 'local', shared_words: best ? best.n : 0 };
211
+ if (!best || best.n < LOCAL_MIN) return none;
212
+ if (best.n === second) flags.push('local-tie');
213
+ const q = pickQuestions([best.i], text)[0];
214
+ const answers = q && q.score >= LOCAL_MIN ? { initiative: best.i.id, n: q.n, text: q.text.slice(0, 200), band: 'possible' } : null;
215
+ // the relation is left for the PM unless an open question is named: a word match cannot tell "validates" from "contradicts"
216
+ return { ...none, initiative: best.i.id, initiative_band: 'possible', relation: answers ? 'answers' : null, relation_band: answers ? 'possible' : null,
217
+ answers_question: answers };
218
+ }
219
+
197
220
  /** Deterministic shuffle: Jev leans toward the first option, so the order changes with the pass. */
198
221
  function shuffled(arr, seed) {
199
222
  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]);
@@ -222,7 +245,7 @@ function requestFor(segment, items, pass = 0) {
222
245
  confirmed: { type: 'noul', instructions: 'The speaker states this as decided or agreed, not as an idea, a question or an opinion.' },
223
246
  };
224
247
  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;
248
+ const state = 'TALKING POINT:\n' + segment.text; // D85: a speaker's name is never sent
226
249
  return { state, questions, asked };
227
250
  }
228
251
 
@@ -470,7 +493,7 @@ function readLedger(file) {
470
493
  * @param {object} [a.askOpts]
471
494
  * @param {Map|null} [a.agentAnswers]
472
495
  */
473
- function openSession({ root, runtimeDir, session = null, key = null, flags = {}, exclude = [], anchor = null, ask = null, askOpts = {}, agentAnswers = null, now = Date.now() }) {
496
+ function openSession({ root, runtimeDir, session = null, key = null, flags = {}, exclude = [], anchor = null, ask = null, askOpts = {}, agentAnswers = null, local = false, now = Date.now() }) {
474
497
  if (session && !SAFE_ID.test(session)) throw new Error('a session id is letters, digits, dots, dashes and underscores');
475
498
  const id = session || newSessionId(now);
476
499
  const P = sessionPaths({ root, runtimeDir, session: id });
@@ -481,8 +504,11 @@ function openSession({ root, runtimeDir, session = null, key = null, flags = {},
481
504
  const state = st0 || { started: nowISO(now), rejected: 0, unreachable: 0, paused: null, n: 0, candidates: {}, closed: false };
482
505
  if (readLedger(P.ledger).some((r) => r.row === 'close')) state.closed = true;
483
506
  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';
507
+ const isPrivate = readLedger(P.ledger).some((r) => r.row === 'private');
508
+ const jevPath = g.open && typeof ask === 'function' && !isPrivate; // D85: nothing from a private session is ever sent
509
+ const route = jevPath ? 'jev' : local ? 'local' : g.open ? 'jev-agent' : 'agent-only';
510
+ // a resumed session keeps the anchor it started with, so its shortlist (and what a decision may name) does not change
511
+ if (anchor == null) { const first = readLedger(P.ledger).find((r) => r.row === 'session'); if (first && first.anchor) anchor = first.anchor; }
486
512
  const loaded = loadInitiatives(root);
487
513
  const sl = shortlist(loaded.open, { anchor });
488
514
  const items = sl.ok ? attachQuestions(root, sl.list, exclude) : [];
@@ -501,8 +527,9 @@ function openSession({ root, runtimeDir, session = null, key = null, flags = {},
501
527
  }
502
528
  const failure = (res, at) => {
503
529
  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) };
530
+ const goesOn = local ? 'The local word match continues.' : 'The agent-only path continues.';
531
+ if (state.rejected >= REJECTED_LIMIT) state.paused = { reason: 'The service rejected the key twice, so Jev is paused for this session. ' + goesOn, at: nowISO(at) };
532
+ 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. ' + goesOn, at: nowISO(at) };
506
533
  if (state.paused) append({ row: 'pause', at, reason: state.paused.reason });
507
534
  };
508
535
 
@@ -534,7 +561,7 @@ function openSession({ root, runtimeDir, session = null, key = null, flags = {},
534
561
  fs.appendFileSync(P.transcript, usable.map((s) => JSON.stringify(s)).join('\n') + '\n');
535
562
  }
536
563
  const useJev = jevPath && !state.paused;
537
- result.path = useJev ? 'jev' : route === 'jev-agent' ? 'jev-agent' : 'agent-only';
564
+ result.path = useJev ? 'jev' : local ? 'local' : route === 'jev-agent' ? route : 'agent-only';
538
565
  const pendingSegs = [];
539
566
  for (const s of usable) {
540
567
  const pointer = { kind: 'live', source: s.source, id: s.id, at: s.at, speaker: s.speaker };
@@ -542,7 +569,16 @@ function openSession({ root, runtimeDir, session = null, key = null, flags = {},
542
569
  if (useJev && !state.paused) {
543
570
  by = 'jev';
544
571
  let { req, res } = await ask1(s, 0);
545
- if (!res.ok) { result.failed++; failure(res, Date.now()); pendingSegs.push(s); continue; }
572
+ if (!res.ok) {
573
+ result.failed++; failure(res, Date.now());
574
+ if (!local) { pendingSegs.push(s); continue; }
575
+ // the view never leaves a point blank: the local word match answers, and the card says Jev was not reached
576
+ prop = localProposal(s.text, items); prop.flags.push('jev-not-reached'); by = 'local';
577
+ }
578
+ if (prop) {
579
+ const row = { row: 'segment', at: s.at, segment: s.id, hash: sha(s.text), pointer, quote: quoteOf(s.text), path: 'local', model: null, ...prop, status: 'proposed' };
580
+ append(row); result.rows.push(row); continue;
581
+ }
546
582
  state.rejected = 0; state.unreachable = 0;
547
583
  let m = mapJev(res.answers, req, ids);
548
584
  model = res.model || null;
@@ -556,14 +592,20 @@ function openSession({ root, runtimeDir, session = null, key = null, flags = {},
556
592
  model = again.res.model || model;
557
593
  } else { result.failed++; failure(again.res, Date.now()); }
558
594
  }
595
+ if (!m && local) {
596
+ prop = localProposal(s.text, items); prop.flags.push('jev-unreadable');
597
+ const row = { row: 'segment', at: s.at, segment: s.id, hash: sha(s.text), pointer, quote: quoteOf(s.text), path: 'local', model, ...prop, status: 'proposed' };
598
+ append(row); result.rows.push(row); continue;
599
+ }
559
600
  if (!m) { append({ row: 'unreadable', at: s.at, segment: s.id, pointer }); result.rows.push({ segment: s.id, status: 'unreadable' }); continue; }
560
601
  prop = proposalFrom(m, { flags: [], inconsistent });
561
602
  } else {
562
603
  const a = (opts.agentAnswers || agentAnswers || new Map()).get(s.id);
563
- if (!a) { pendingSegs.push(s); continue; }
564
- prop = proposalFromAgent(a);
604
+ if (a) prop = proposalFromAgent(a);
605
+ else if (local) { prop = localProposal(s.text, items); by = 'local'; }
606
+ else { pendingSegs.push(s); continue; }
565
607
  }
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' };
608
+ const row = { row: 'segment', at: s.at, segment: s.id, hash: sha(s.text), pointer, quote: quoteOf(s.text), path: by === 'jev' ? 'jev' : by === 'local' ? 'local' : 'agent-only', model, ...prop, status: 'proposed' };
567
609
  append(row);
568
610
  result.rows.push(row);
569
611
  }
@@ -676,7 +718,7 @@ function openSession({ root, runtimeDir, session = null, key = null, flags = {},
676
718
  }
677
719
 
678
720
  /** The PM's accept, change or reject. Appended; never overwrites a proposal. */
679
- function decide({ segment, action, initiative = null, relation = null, by = 'pm' }) {
721
+ function decide({ segment, action, initiative = null, relation = null, question = null, by = 'pm' }) {
680
722
  if (!['accept', 'change', 'reject'].includes(action)) throw new Error('action is accept, change or reject');
681
723
  const prop = readLedger(P.ledger).filter((r) => r.row === 'segment' && r.segment === segment).pop();
682
724
  if (!prop) throw new Error('no such segment in this session: ' + segment);
@@ -684,10 +726,25 @@ function openSession({ root, runtimeDir, session = null, key = null, flags = {},
684
726
  if (action === 'change') {
685
727
  if (initiative && initiative !== 'none' && !ids.includes(initiative)) throw new Error('that initiative is not in the shortlist');
686
728
  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');
729
+ if (!initiative && !relation && question == null) throw new Error('a change names an initiative, a relation, or both');
688
730
  }
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' };
731
+ const finalIni = action === 'change' ? (initiative || prop.initiative) : prop.initiative;
732
+ const finalRel = action === 'change' ? (finalIni === 'none' ? 'none' : (relation || prop.relation)) : prop.relation;
733
+ if (action === 'accept' && finalIni && finalIni !== 'none' && !finalRel) throw new Error('pick how the point relates to the initiative before confirming it');
734
+ if (action === 'change' && finalIni !== 'none' && !finalRel) throw new Error('pick how the point relates to the initiative');
735
+ // "answers" holds only with a named open question of the chosen initiative (N4)
736
+ let answered = null;
737
+ if (action !== 'reject' && finalRel === 'answers') {
738
+ const item = items.find((i) => i.id === finalIni);
739
+ const qs = (item && item.questions) || [];
740
+ const n = question != null ? Number(question) : (prop.answers_question && prop.answers_question.initiative === finalIni ? prop.answers_question.n : null);
741
+ if (!Number.isInteger(n) || n < 1 || n > qs.length) throw new Error(qs.length ? 'name which open question of ' + finalIni + ' it answers' : finalIni + ' has no open questions, so the point cannot answer one');
742
+ answered = { n, text: qs[n - 1].slice(0, 200) };
743
+ }
744
+ const changed = action === 'change' && (finalIni !== prop.initiative || finalRel !== prop.relation || (answered && (!prop.answers_question || prop.answers_question.n !== answered.n)));
745
+ const proposedBy = prop.path === 'jev' || prop.path === 'jev-agent' ? 'jev' : prop.path === 'local' ? 'local' : 'agent';
746
+ const row = { row: 'decision', segment, action: action === 'change' && !changed ? 'accept' : action, by, proposed_by: proposedBy, was: { initiative: prop.initiative, relation: prop.relation },
747
+ now: changed ? { initiative: finalIni, relation: finalRel, ...(answered ? { answers_question: answered } : {}) } : null, overridden: action === 'reject' || changed };
691
748
  append(row);
692
749
  return row;
693
750
  }
@@ -713,7 +770,7 @@ function openSession({ root, runtimeDir, session = null, key = null, flags = {},
713
770
  return { note: note.rel, transcript: 'kept until ' + nowISO(at + retentionDays * 86400000) };
714
771
  }
715
772
 
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 }) };
773
+ return { id, paths: P, items, shortlistError: sl.ok ? null : sl.error, anchor, path: route, private: isPrivate, gates: g, add, answer, plan, addContext, addDrafts, decide, close, ledger: () => readLedger(P.ledger), state: () => ({ ...state }) };
717
774
  }
718
775
 
719
776
  /** Delete transcripts whose retention has passed. */
@@ -757,21 +814,27 @@ function writeNote({ root, session, at, items, decisions, ctx = new Map(), draft
757
814
  const word = { contradicts: 'contradicts', answers: 'answers an open question of', validates: 'validates', clarifies: 'clarifies', extends: 'extends' };
758
815
  const line = (r) => {
759
816
  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';
817
+ const where = r.initiative && r.initiative !== 'none' ? `${r.relation ? word[r.relation] || r.relation : 'is about'} \`${r.initiative}\` (${r.relation_band || r.initiative_band})` : 'matches no initiative';
761
818
  const tail = dec ? ` — PM: ${dec.action}${dec.now ? ' → ' + [dec.now.initiative, dec.now.relation].filter(Boolean).join(' / ') : ''}` : '';
762
819
  const c = ctx.get(r.segment);
763
820
  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
821
  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
822
  };
766
- const answered = items.filter((r) => r.status === 'proposed');
823
+ // the PM's decision wins: a changed point is filed as changed, a rejected one is counted and left out
824
+ const rejected = items.filter((r) => r.status === 'proposed' && decisions.get(r.segment) && decisions.get(r.segment).action === 'reject').length;
825
+ const answered = items.filter((r) => r.status === 'proposed').map((r) => {
826
+ const d = decisions.get(r.segment);
827
+ if (d && d.action === 'reject') return null;
828
+ return d && d.now ? { ...r, initiative: d.now.initiative, relation: d.now.relation, answers_question: d.now.answers_question || r.answers_question, relation_band: 'confirmed by the PM' } : r;
829
+ }).filter(Boolean);
767
830
  const body = [];
768
831
  body.push(`## Live notes ${hm} (session ${session})`, '');
769
832
  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) || []), '');
833
+ body.push('### New or changed requirements', ...(answered.filter((r) => ['extends', 'clarifies', 'none', null, undefined].includes(r.relation)).map(line) || []), '');
771
834
  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
835
  body.push('### Open questions', ...answered.filter((r) => r.relation === 'answers').map((r) => line(r) + (r.answers_question ? `; answers: ${r.answers_question.text}` : '')),
773
836
  ...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).`, '');
837
+ body.push('### Sources reached and not reached', `- live conversation: ${items.length} point(s) proposed, ${items.filter((r) => r.status === 'pending').length} still unanswered, ${rejected} rejected by the PM, ${withheld} withheld (secret-shaped or private-marked, not read further).`, '');
775
838
  fs.mkdirSync(dir, { recursive: true });
776
839
  let exists = false;
777
840
  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; }
@@ -828,6 +891,7 @@ function rank(rows) {
828
891
  paused: paused ? { reason: paused.reason, at: paused.at } : null,
829
892
  path: (rows.find((r) => r.row === 'session') || {}).path || null,
830
893
  closed: rows.some((r) => r.row === 'close'),
894
+ private: rows.some((r) => r.row === 'private'),
831
895
  counts: { points: segs.size, pending: sections.pending.length },
832
896
  };
833
897
  }
@@ -848,26 +912,107 @@ function sessionsOf(root) {
848
912
  }
849
913
  const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,60}$/;
850
914
  /** GET: the sessions, or one session ranked. Reads only; writes nothing. */
851
- function apiList({ root, session = null }) {
915
+ function apiList({ root, session = null, exclude = [] }) {
852
916
  if (!root) return { ok: false, status: 400, error: 'no repository' };
853
917
  if (!session) return { ok: true, sessions: sessionsOf(root), note: 'Proposals only. Nothing here is a decision.' };
854
918
  if (!SESSION_RE.test(session)) return { ok: false, status: 400, error: 'that is not a session id' };
855
919
  const file = path.join(stateDirIn(root), 'live-notes', session + '.jsonl');
856
920
  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.' };
921
+ const rows = readLedger(file);
922
+ const first = rows.find((r) => r.row === 'session') || {};
923
+ const sl = shortlist(loadInitiatives(root).open, { anchor: first.anchor || null });
924
+ const list = sl.ok ? attachQuestions(root, sl.list, exclude).map((i) => ({ id: i.id, title: i.title, questions: i.questions || [] })) : [];
925
+ return { ok: true, session, anchor: first.anchor || null, ranked: rank(rows), shortlist: list, note: 'Proposals only. Nothing here is a decision.' };
858
926
  }
859
927
  /** 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 = [] }) {
928
+ function apiDecide({ root, runtimeDir, session, segment, action, initiative = null, relation = null, question = null, exclude = [] }) {
861
929
  if (!root) return { ok: false, status: 400, error: 'no repository' };
862
930
  if (!SESSION_RE.test(String(session || ''))) return { ok: false, status: 400, error: 'that is not a session id' };
863
931
  if (!fs.existsSync(path.join(stateDirIn(root), 'live-notes', session + '.jsonl'))) return { ok: false, status: 404, error: 'no such session in this repository' };
864
932
  try {
865
933
  const s = openSession({ root, runtimeDir, session, key: null, flags: {}, exclude, ask: null });
866
- const row = s.decide({ segment, action, initiative, relation, by: 'pm' });
934
+ const row = s.decide({ segment, action, initiative, relation, question, by: 'pm' });
867
935
  return { ok: true, decision: row };
868
936
  } catch (e) { return { ok: false, status: 400, error: String((e && e.message) || e) }; }
869
937
  }
870
938
 
939
+ /* ── Run 6: clean up a recognised phrase before it becomes a point. Local and deterministic: no model. ─────────────── */
940
+ const FILLERS = /\b(?:um+|uh+|uhm+|er+m*|hmm+|mm+)\b[,.]?\s*/gi;
941
+ function cleanPhrase(raw) {
942
+ let t = String(raw == null ? '' : raw).replace(/\s+/g, ' ').trim();
943
+ t = t.replace(FILLERS, '').replace(/\s+/g, ' ').trim();
944
+ // an immediate repeat ("the the", "we we will") is a stumble, not meaning
945
+ t = t.replace(/\b(\w+)(?:\s+\1\b)+/gi, '$1');
946
+ t = t.replace(/\s+([,.;:?!])/g, '$1').replace(/^[,.;:\s]+/, '');
947
+ if (!t) return '';
948
+ t = t[0].toUpperCase() + t.slice(1);
949
+ if (!/[.?!]$/.test(t)) t += '.';
950
+ return t;
951
+ }
952
+
953
+ /* ── Run 6: the view's three writes. Each opens the session on the local path: the server never sends anything. ───── */
954
+ const ledgerOf = (root, session) => path.join(stateDirIn(root), 'live-notes', session + '.jsonl');
955
+ const shortlistOf = (s) => s.items.map((i) => ({ id: i.id, title: i.title, questions: i.questions || [] }));
956
+ /** Anchors with their open-initiative counts, for narrowing a shortlist that is past the Choice cap. */
957
+ function anchorsOf(root) {
958
+ const counts = {};
959
+ for (const i of loadInitiatives(root).open) if (i.anchor) counts[i.anchor] = (counts[i.anchor] || 0) + 1;
960
+ return Object.entries(counts).sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).map(([anchor, open]) => ({ anchor, open }));
961
+ }
962
+ /** POST: start a session, or resume one. Returns its shortlist so the view can offer only those initiatives. */
963
+ function apiOpen({ root, runtimeDir, session = null, anchor = null, exclude = [] }) {
964
+ if (!root) return { ok: false, status: 400, error: 'no repository' };
965
+ if (session && !SESSION_RE.test(String(session))) return { ok: false, status: 400, error: 'that is not a session id' };
966
+ if (anchor != null && (typeof anchor !== 'string' || !/^[\w:.-]{1,80}$/.test(anchor))) return { ok: false, status: 400, error: 'that is not an anchor' };
967
+ // with no anchor, a shortlist past the cap is refused BEFORE any ledger is written, and the anchors are offered
968
+ if (!session && !anchor) { const sl = shortlist(loadInitiatives(root).open); if (!sl.ok) return { ok: false, status: 409, error: sl.error, anchors: anchorsOf(root) }; }
969
+ if (session && !fs.existsSync(ledgerOf(root, session))) return { ok: false, status: 404, error: 'no such session in this repository' };
970
+ try {
971
+ const s = openSession({ root, runtimeDir, session, key: null, flags: {}, exclude, anchor, ask: null, local: true });
972
+ if (s.shortlistError) return { ok: false, status: 409, error: s.shortlistError, anchors: anchorsOf(root) };
973
+ return { ok: true, session: s.id, path: s.path, anchor: s.anchor, closed: s.state().closed, shortlist: shortlistOf(s), note: 'Proposals here are a local word match. Nothing leaves this machine.' };
974
+ } catch (e) { return { ok: false, status: 400, error: String((e && e.message) || e) }; }
975
+ }
976
+ /** POST: add talking points (one per line) to an open session. Each gets a local proposal at once. */
977
+ async function apiAdd({ root, runtimeDir, session, text, source = 'typed', exclude = [], key = null, flags = {}, ask = null, askOpts = {} }) {
978
+ const spoken = source === 'speech';
979
+ if (!root) return { ok: false, status: 400, error: 'no repository' };
980
+ if (!SESSION_RE.test(String(session || ''))) return { ok: false, status: 400, error: 'that is not a session id' };
981
+ if (!fs.existsSync(ledgerOf(root, session))) return { ok: false, status: 404, error: 'no such session in this repository' };
982
+ const lines = String(text == null ? '' : text).split(/\r?\n/).map((l) => (spoken ? cleanPhrase(l) : l.trim())).filter((l) => l && (!spoken || l.length >= MIN_SEGMENT_CHARS));
983
+ if (!lines.length) return { ok: false, status: 400, error: 'type a talking point first' };
984
+ if (lines.length > 200 || String(text).length > 40000) return { ok: false, status: 413, error: 'that is too much at once: add at most 200 lines' };
985
+ try {
986
+ const s = openSession({ root, runtimeDir, session, key, flags, exclude, ask, askOpts, local: true });
987
+ if (s.state().closed) return { ok: false, status: 409, error: 'this session is closed' };
988
+ const r = await s.add(lines, { source: SAFE_ID.test(source) ? source : 'typed' });
989
+ if (r.error) return { ok: false, status: 409, error: r.error };
990
+ return { ok: true, added: r.rows.map((x) => x.segment), cleaned: spoken ? lines : undefined, withheld: r.withheld, path: r.path, failed: r.failed, paused: r.paused || null, ranked: rank(s.ledger()) };
991
+ } catch (e) { return { ok: false, status: 400, error: String((e && e.message) || e) }; }
992
+ }
993
+ /** POST: mark a session private. The view stops listening; Run 7 never sends a point from a private session. */
994
+ function apiMarkPrivate({ root, session }) {
995
+ if (!root) return { ok: false, status: 400, error: 'no repository' };
996
+ if (!SESSION_RE.test(String(session || ''))) return { ok: false, status: 400, error: 'that is not a session id' };
997
+ const file = ledgerOf(root, session);
998
+ if (!fs.existsSync(file)) return { ok: false, status: 404, error: 'no such session in this repository' };
999
+ const rows = readLedger(file);
1000
+ if (rows.some((r) => r.row === 'private')) return { ok: true, already: true };
1001
+ createSinkGuard({ sink: file, what: 'live-notes' }).appendLine(file, { row: 'private', session, at: nowISO(Date.now()), by: 'pm' });
1002
+ return { ok: true };
1003
+ }
1004
+ /** POST: close a session: the day's note gets its section and the raw transcript goes (or is dated to go). */
1005
+ function apiClose({ root, runtimeDir, session, retentionDays = 0, exclude = [] }) {
1006
+ if (!root) return { ok: false, status: 400, error: 'no repository' };
1007
+ if (!SESSION_RE.test(String(session || ''))) return { ok: false, status: 400, error: 'that is not a session id' };
1008
+ if (!fs.existsSync(ledgerOf(root, session))) return { ok: false, status: 404, error: 'no such session in this repository' };
1009
+ try {
1010
+ const s = openSession({ root, runtimeDir, session, key: null, flags: {}, exclude, ask: null, local: true });
1011
+ const r = s.close({ retentionDays });
1012
+ return r.already ? { ok: true, already: true } : { ok: true, note: r.note, transcript: r.transcript };
1013
+ } catch (e) { return { ok: false, status: 400, error: String((e && e.message) || e) }; }
1014
+ }
1015
+
871
1016
  /* ── the override rate (N13): the first measure, never an accuracy claim ─────────────────────────── */
872
1017
  function overrideRate(rows) {
873
1018
  const by = {};
@@ -876,7 +1021,7 @@ function overrideRate(rows) {
876
1021
  const dec = new Map();
877
1022
  for (const r of rows) if (r.row === 'decision') dec.set(r.segment, r);
878
1023
  for (const [id, s] of seg) {
879
- const p = s.path === 'jev' || s.path === 'jev-agent' ? s.path : 'agent-only';
1024
+ const p = ['jev', 'jev-agent', 'local'].includes(s.path) ? s.path : 'agent-only';
880
1025
  by[p] = by[p] || { proposed: 0, decided: 0, overridden: 0 };
881
1026
  by[p].proposed++;
882
1027
  const d = dec.get(id);
@@ -886,5 +1031,5 @@ function overrideRate(rows) {
886
1031
  return by;
887
1032
  }
888
1033
 
889
- module.exports = { rank, sessionsOf, apiList, apiDecide, segmentsFromTranscript, contextPlan, parseContext, parseDrafts, checkCite, LANES, openSession, purgeExpired, getSwitch, setSwitch, gates, gateSentence, band, shortlist, loadInitiatives, openQuestionsOf, requestFor,
1034
+ module.exports = { rank, sessionsOf, apiList, apiDecide, apiOpen, apiAdd, apiClose, apiMarkPrivate, cleanPhrase, anchorsOf, localProposal, LOCAL_MIN, segmentsFromTranscript, contextPlan, parseContext, parseDrafts, checkCite, LANES, openSession, purgeExpired, getSwitch, setSwitch, gates, gateSentence, band, shortlist, loadInitiatives, openQuestionsOf, requestFor,
890
1035
  mapJev, parseAgentAnswers, agentPrompt, normalizeSegments, overrideRate, readLedger, sessionPaths, stateDirIn, isExcluded, RELATIONS, MAX_CHOICE, REJECTED_LIMIT, UNREACHABLE_LIMIT };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.48.0",
3
+ "version": "0.49.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.48.0",
3
+ "version": "0.49.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",
@@ -16,7 +16,7 @@
16
16
  "test:parity:four-panel": "node design-parity.js --states",
17
17
  "test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
18
18
  "doctor": "node doctor.js",
19
- "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 && node pm-r25-test.js && node pm-r26-test.js && node pm-doc-category-test.js && node pm-r27-test.js && node pm-r28-test.js && node pm-r29-today-test.js && node pm-r29-portfolio-test.js && node pm-r29-knowledge-test.js && node pm-r29-memory-test.js && node pm-r29-skills-scheduled-test.js && node pm-r29-validate-guide-test.js && node pm-r29-reports-addrepo-test.js && node pm-r29-filespad-test.js && node pm-r29-newsession-alignment-test.js",
19
+ "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 && node pm-r25-test.js && node pm-r26-test.js && node pm-doc-category-test.js && node pm-r27-test.js && node pm-r28-test.js && node pm-r29-today-test.js && node pm-r29-portfolio-test.js && node pm-r29-knowledge-test.js && node pm-r29-memory-test.js && node pm-r29-skills-scheduled-test.js && node pm-r29-validate-guide-test.js && node pm-r29-reports-addrepo-test.js && node pm-r29-filespad-test.js && node pm-r29-newsession-alignment-test.js && node pm-live-notes-test.js && node pm-r30-test.js",
20
20
  "test:offline": "node offline-tarball.test.js"
21
21
  },
22
22
  "engines": {