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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
- package/payload/mods/pmos-cockpit/README.md +10 -1
- package/payload/mods/pmos-cockpit/hooks/lib/route.mjs +95 -0
- package/payload/mods/pmos-cockpit/hooks/register.tsx +67 -1
- package/payload/mods/pmos-cockpit/types/index.d.ts +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/status-facts.js +13 -3
- package/payload/scripts/workflow-state.js +7 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/guide/index.md +2 -1
- package/workspace/guide/initiatives-and-next-steps.md +3 -1
- package/workspace/guide/knowledge-graph.md +12 -1
- package/workspace/guide/layout.md +2 -0
- package/workspace/guide/live-notes.md +78 -0
- package/workspace/guide/validate-with-typesafe.md +15 -1
- package/workspace/lib/engine/status-facts.js +13 -3
- package/workspace/lib/engine/workflow-state.js +7 -2
- package/workspace/lib/jev-categorize.js +47 -13
- package/workspace/lib/jev-live.js +37 -0
- package/workspace/lib/jev-route.js +11 -1
- package/workspace/lib/live-notes.js +171 -26
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +21 -1
- package/workspace/public/pm/modes-live.js +68 -5
- package/workspace/public/pm/panel-cockpit.js +3 -1
- package/workspace/public/pm/panel-detail.js +3 -1
- package/workspace/public/pm/panel-tabs.js +112 -10
- package/workspace/public/pm/state.js +3 -0
- package/workspace/public/pm/view-livenotes.js +397 -0
- 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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' +
|
|
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
|
|
485
|
-
const
|
|
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
|
-
|
|
505
|
-
|
|
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' ?
|
|
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) {
|
|
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 (
|
|
564
|
-
prop =
|
|
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
|
|
690
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 };
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "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": {
|