create-pmos 0.46.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/AGENTS-HUMAN.md +1 -1
- package/payload/AGENTS.md +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
- package/payload/mods/pmos-cockpit/README.md +13 -3
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +34 -6
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +22 -3
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +16 -3
- package/payload/mods/pmos-cockpit/hooks/lib/handover-run.mjs +33 -0
- package/payload/mods/pmos-cockpit/hooks/lib/handover.mjs +267 -0
- package/payload/mods/pmos-cockpit/hooks/register.tsx +189 -18
- package/payload/mods/pmos-cockpit/types/index.d.ts +16 -1
- package/payload/okf/core/concepts/frontmatter-format.md +6 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/pmos-log.sh +18 -8
- package/payload/scripts/pmos-model.js +233 -2
- package/payload/scripts/status-facts.js +204 -0
- package/payload/scripts/workflow-state.js +52 -0
- package/payload/skills/SKILL-FORMAT.md +1 -0
- package/payload/state/discoveries.md +1 -1
- package/workspace/README.md +3 -3
- package/workspace/bin/live-notes.js +133 -0
- package/workspace/design-parity.js +328 -21
- package/workspace/doc-category-bench.js +165 -0
- package/workspace/fixtures/doc-category-labelled.json +167 -0
- package/workspace/guide/initiatives-and-next-steps.md +16 -2
- package/workspace/guide/knowledge-graph.md +89 -2
- package/workspace/guide/layout.md +30 -2
- package/workspace/guide/proactive-engine.md +26 -4
- package/workspace/guide/scheduled-tasks-and-cli.md +1 -0
- package/workspace/guide/statuses.md +1 -0
- package/workspace/guide/validate-with-typesafe.md +17 -1
- package/workspace/lib/claims.js +51 -2
- package/workspace/lib/doc-category-corrections.js +113 -0
- package/workspace/lib/doc-category-writeback.js +110 -0
- package/workspace/lib/doc-category.js +225 -0
- package/workspace/lib/engine/pmos-model.js +233 -2
- package/workspace/lib/engine/status-facts.js +204 -0
- package/workspace/lib/engine/workflow-state.js +52 -0
- package/workspace/lib/jev-categorize.js +399 -0
- package/workspace/lib/jev-claims.js +59 -15
- package/workspace/lib/jev-route.js +129 -0
- package/workspace/lib/kg-cli.js +5 -2
- package/workspace/lib/kg-dangling-baseline.json +1 -0
- package/workspace/lib/kg-dangling-rules.json +1 -0
- package/workspace/lib/kg.js +26 -9
- package/workspace/lib/learn.js +282 -0
- package/workspace/lib/live-notes.js +890 -0
- package/workspace/lib/proactive-cli.js +49 -1
- package/workspace/lib/proactive.js +8 -7
- package/workspace/lib/typesafe-settings.js +10 -2
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +5 -4
- package/workspace/public/pm/app.js +148 -79
- package/workspace/public/pm/data.js +58 -0
- package/workspace/public/pm/fixture.js +40 -2
- package/workspace/public/pm/modes-live.js +267 -21
- package/workspace/public/pm/panel-cockpit.js +636 -0
- package/workspace/public/pm/panel-detail.js +210 -8
- package/workspace/public/pm/panel-initiatives.js +9 -9
- package/workspace/public/pm/panel-newsess.js +145 -86
- package/workspace/public/pm/panel-repos.js +34 -17
- package/workspace/public/pm/panel-tabs.js +145 -27
- package/workspace/public/pm/state.js +45 -4
- package/workspace/public/pm/tab-doc.js +244 -16
- package/workspace/public/pm/tab-term.js +11 -1
- package/workspace/public/pm/tokens.css +4 -3
- package/workspace/public/pm/view-addrepo.js +207 -0
- package/workspace/public/pm/view-files.js +197 -0
- package/workspace/public/pm/view-guide.js +141 -0
- package/workspace/public/pm/view-knowledge.js +225 -0
- package/workspace/public/pm/view-memory.js +122 -0
- package/workspace/public/pm/view-portfolio.js +153 -0
- package/workspace/public/pm/view-reports.js +190 -0
- package/workspace/public/pm/view-scheduled.js +161 -0
- package/workspace/public/pm/view-skills.js +216 -0
- package/workspace/public/pm/view-today.js +168 -0
- package/workspace/public/pm/view-validate.js +171 -0
- package/workspace/server.js +394 -10
- package/workspace/skills/live-notes.skill +58 -0
- package/workspace/skills/morning-brief.skill +1 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* jev-categorize.js — ask Jev what kind of document a file is, only when the local rules cannot tell (D82).
|
|
3
|
+
*
|
|
4
|
+
* This is the BRIDGE between the document categorizer (lib/doc-category.js, local and pure) and the one TypeSafe module
|
|
5
|
+
* (lib/typesafe.js). `doc-category.js` never reaches that module; this file is the categorizer's only way to it.
|
|
6
|
+
*
|
|
7
|
+
* WHAT MAY LEAVE THE MACHINE. For a Markdown file the local rules leave `undetermined`: its title, its headings and the
|
|
8
|
+
* opening of its body, at most SNIPPET_MAX characters, and one Choice question over the categories. Never the path, never
|
|
9
|
+
* the frontmatter, never the whole file. Only when ALL THREE gates hold: a saved key, D79 consent for the repository, and
|
|
10
|
+
* the "categorize with Jev" switch. A file under an excluded path, or a snippet the secret scan matches, is withheld.
|
|
11
|
+
*
|
|
12
|
+
* HOW IT STAYS CHEAP. The first run shows its request count and sends only after the PM confirms that number. Afterwards
|
|
13
|
+
* a pass sends only snippets with no cached answer (a new file, or a changed one); a pass of more than AUTO_LIMIT waits for
|
|
14
|
+
* the same confirmation. An answer is cached by the hash of the snippet, the question and the model, so an unchanged
|
|
15
|
+
* snippet, or a revert, is never asked twice.
|
|
16
|
+
*
|
|
17
|
+
* WHAT A RESULT IS. Advisory (D40), basis `jev`, shown in words. The ticket options are summed into one `ticket` first;
|
|
18
|
+
* a top category under 0.6, or within 0.1 of the next, is `undetermined` with the two leading options named (C6).
|
|
19
|
+
*
|
|
20
|
+
* WHAT IT NEVER DOES. It writes nothing into the repository. Its state (confirmation, pause, cache) lives in the per-user
|
|
21
|
+
* cache directory the caller passes. Two rejected keys or three failed asks in a row pause the repository until resumed.
|
|
22
|
+
*/
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
const DC = require('./doc-category');
|
|
27
|
+
const scanner = require('./secret-scan');
|
|
28
|
+
const settings = require('./typesafe-settings');
|
|
29
|
+
const typesafe = require('./typesafe');
|
|
30
|
+
|
|
31
|
+
const SNIPPET_MAX = 3000;
|
|
32
|
+
const HEADINGS_MAX = 1000;
|
|
33
|
+
const AUTO_LIMIT = 25;
|
|
34
|
+
const MAX_CACHE = 5000;
|
|
35
|
+
const MAX_LISTED = 20;
|
|
36
|
+
const REJECTED_LIMIT = 2;
|
|
37
|
+
const UNREACHABLE_LIMIT = 3;
|
|
38
|
+
const UNSURE_BELOW = 0.6;
|
|
39
|
+
const UNSURE_GAP = 0.1;
|
|
40
|
+
const UNREADABLE_LIMIT = 3;
|
|
41
|
+
const sha = (s) => crypto.createHash('sha1').update(s).digest('hex');
|
|
42
|
+
|
|
43
|
+
/* The Choice options. A ticket is three options, so one question decides the category and the ticket kind. */
|
|
44
|
+
const OPTIONS = {
|
|
45
|
+
prd: 'A product requirements document: the problem, the requirements and how success is judged.',
|
|
46
|
+
'meeting-notes': 'Notes from a meeting: who attended, what was discussed, decisions and action items.',
|
|
47
|
+
report: 'A report: a status update, findings, results, metrics or a post-mortem about work already done.',
|
|
48
|
+
'ticket-design': 'A ticket for design work: a UI, UX or visual change, with acceptance criteria.',
|
|
49
|
+
'ticket-engineering': 'A ticket for engineering work: a bug, a defect or a technical task, with steps or acceptance criteria.',
|
|
50
|
+
'ticket-other': 'A ticket for any other kind of work item.',
|
|
51
|
+
knowledge: 'Explanatory knowledge: a concept, a guide, a how-to or reference material.',
|
|
52
|
+
'spec-artifact': 'A control record of the delivery process: a rubric, an initiative record, a user story or a decision record.',
|
|
53
|
+
other: 'None of the kinds above.',
|
|
54
|
+
};
|
|
55
|
+
const INSTRUCTIONS = 'Which kind of document is this? Judge from its title, its headings and its opening, given below.';
|
|
56
|
+
// The question's own text is in the cache key, so editing an option or the instructions asks again; no version tag to forget.
|
|
57
|
+
const QUESTION_HASH = sha(JSON.stringify([INSTRUCTIONS, OPTIONS]));
|
|
58
|
+
|
|
59
|
+
const wordsFor = (p) => (p >= 0.85 ? 'very likely' : 'likely');
|
|
60
|
+
|
|
61
|
+
/** Which of the three gates is shut. `open` only when none is. */
|
|
62
|
+
function gates({ key, consent, categorize }) {
|
|
63
|
+
const missing = [];
|
|
64
|
+
if (!key) missing.push('key');
|
|
65
|
+
if (!consent) missing.push('consent');
|
|
66
|
+
if (!categorize) missing.push('categorize');
|
|
67
|
+
return { open: missing.length === 0, missing };
|
|
68
|
+
}
|
|
69
|
+
const GATE_WORDS = {
|
|
70
|
+
key: 'No TypeSafe key is saved.',
|
|
71
|
+
consent: 'Validation is not switched on for this repository.',
|
|
72
|
+
categorize: 'Categorizing with Jev is off for this repository.',
|
|
73
|
+
};
|
|
74
|
+
const gateSentence = (missing) => missing.map((m) => GATE_WORDS[m]).join(' ');
|
|
75
|
+
|
|
76
|
+
/* ── the snippet ───────────────────────────────────────────────────────────────────────────────── */
|
|
77
|
+
const FRONTMATTER = /^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
78
|
+
const clip = (s, n) => (s.length > n ? s.slice(0, n) : s);
|
|
79
|
+
|
|
80
|
+
/** The text that may be sent for one document: title, headings, opening. Never longer than SNIPPET_MAX. Pure. */
|
|
81
|
+
function snippetFor({ fm = {}, text = '' }) {
|
|
82
|
+
const body = String(text || '').replace(/^\uFEFF/, '').replace(FRONTMATTER, '');
|
|
83
|
+
const heads = [];
|
|
84
|
+
for (const m of body.matchAll(/^#{1,6}\s+(.+?)\s*#*\s*$/gm)) heads.push(clip(m[1].replace(/\s+/g, ' '), 120));
|
|
85
|
+
const title = clip(String((fm && fm.title) || heads[0] || '').replace(/\s+/g, ' ').trim(), 200) || '(none)';
|
|
86
|
+
let hs = '';
|
|
87
|
+
for (const h of heads) { const line = '- ' + h + '\n'; if (hs.length + line.length > HEADINGS_MAX) break; hs += line; }
|
|
88
|
+
const head = `TITLE: ${title}\nHEADINGS:\n${hs || '(none)\n'}\nOPENING:\n`;
|
|
89
|
+
const opening = body.replace(/^#{1,6}\s+.*$/gm, '').replace(/\s+/g, ' ').trim();
|
|
90
|
+
return clip(head + opening, SNIPPET_MAX);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const keyFor = (snip) => sha([sha(snip), QUESTION_HASH, typesafe.DEFAULT_MODEL].join('\0'));
|
|
94
|
+
const requestFor = (snip) => ({ state: snip, questions: { category: { type: 'choice', instructions: INSTRUCTIONS, criteria: OPTIONS } } });
|
|
95
|
+
|
|
96
|
+
/** The documents the local rules leave undetermined. Only these may ever be sent. Pure. */
|
|
97
|
+
function candidates(docs, named = new Map()) {
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const d of docs || []) {
|
|
100
|
+
const rel = String(d.rel || '').replace(/\\/g, '/');
|
|
101
|
+
if (!/\.md$/i.test(rel)) continue;
|
|
102
|
+
const r = DC.categorize({ rel, fm: d.fm || {}, text: d.text || '', named, correction: d.correction || null }); // a PM correction decides too
|
|
103
|
+
if (r.decided || r.category !== DC.UNDETERMINED) continue;
|
|
104
|
+
out.push({ rel, fm: d.fm || {}, text: d.text || '' });
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ── reading an answer ─────────────────────────────────────────────────────────────────────────── */
|
|
110
|
+
/** The answer's probability per option, or null. Read defensively: the shape is documented only by a third party (D82). */
|
|
111
|
+
function readAnswer(ans) {
|
|
112
|
+
if (!ans || typeof ans !== 'object') return null;
|
|
113
|
+
const probs = {};
|
|
114
|
+
const put = (k, v) => { const n = Number(v); if (Object.prototype.hasOwnProperty.call(OPTIONS, k) && Number.isFinite(n) && n >= 0 && n <= 1) probs[k] = n; };
|
|
115
|
+
const raw = ans.probabilities || ans.distribution;
|
|
116
|
+
if (Array.isArray(raw)) { for (const x of raw) if (x && typeof x === 'object') put(x.option || x.label || x.choice, x.probability != null ? x.probability : x.p); }
|
|
117
|
+
else if (raw && typeof raw === 'object') { for (const [k, v] of Object.entries(raw)) put(k, v); }
|
|
118
|
+
if (!Object.keys(probs).length && typeof ans.choice === 'string') put(ans.choice, ans.confidence);
|
|
119
|
+
if (!Object.keys(probs).length) return null;
|
|
120
|
+
const sum = Object.values(probs).reduce((a, b) => a + b, 0);
|
|
121
|
+
if (sum > 1.05) return null; // not a distribution
|
|
122
|
+
return probs;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** C6 on Jev's answer: fold the ticket options, then decide or stay undetermined with the two leading options. Pure. */
|
|
126
|
+
function decide(probs) {
|
|
127
|
+
const cat = {};
|
|
128
|
+
let ticketKind = null, ticketBest = -1;
|
|
129
|
+
for (const [k, v] of Object.entries(probs || {})) {
|
|
130
|
+
if (k.startsWith('ticket-')) {
|
|
131
|
+
cat.ticket = (cat.ticket || 0) + v;
|
|
132
|
+
if (v > ticketBest) { ticketBest = v; ticketKind = k.slice('ticket-'.length); }
|
|
133
|
+
} else cat[k] = (cat[k] || 0) + v;
|
|
134
|
+
}
|
|
135
|
+
const ranked = Object.entries(cat).sort((a, b) => b[1] - a[1]);
|
|
136
|
+
if (!ranked.length) return null;
|
|
137
|
+
const [top, second] = [ranked[0], ranked[1] || [null, 0]];
|
|
138
|
+
const leading = [top, second].filter((x) => x[0]).map(([category, p]) => ({ category, p: Math.round(p * 1000) / 1000 }));
|
|
139
|
+
if (top[1] < UNSURE_BELOW || top[1] - second[1] < UNSURE_GAP) {
|
|
140
|
+
return { decided: false, category: DC.UNDETERMINED, ticket_kind: null, p: null, leading };
|
|
141
|
+
}
|
|
142
|
+
return { decided: true, category: top[0], ticket_kind: top[0] === 'ticket' ? ticketKind : null, p: Math.round(top[1] * 1000) / 1000, leading };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** One sentence for the page: what Jev said, in words, never the number. */
|
|
146
|
+
function sentenceFor(d) {
|
|
147
|
+
if (!d) return null;
|
|
148
|
+
if (!d.decided) return d.leading.length > 1 ? `Jev was unsure: ${d.leading.map((x) => x.category).join(' or ')} (advisory).` : `Jev was unsure: ${d.leading[0].category}, and it reported no other option (advisory).`;
|
|
149
|
+
return `Jev rates it ${wordsFor(d.p)} that this is ${d.category === 'ticket' ? 'a ' + d.ticket_kind + ' ticket' : d.category} (advisory).`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* ── per-user state ────────────────────────────────────────────────────────────────────────────── */
|
|
153
|
+
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
|
|
154
|
+
function writeJson(f, v) {
|
|
155
|
+
try { fs.mkdirSync(path.dirname(f), { recursive: true }); const tmp = f + '.' + process.pid + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(v)); fs.renameSync(tmp, f); return true; }
|
|
156
|
+
catch { return false; }
|
|
157
|
+
}
|
|
158
|
+
const stateFile = (cacheDir) => path.join(cacheDir, 'jev-categorize.json');
|
|
159
|
+
const cacheFile = (cacheDir) => path.join(cacheDir, 'jev-categorize-cache.json');
|
|
160
|
+
/* One pass at a time per repository, across processes: the workspace's passes and the bench's measured pass write the same
|
|
161
|
+
* cache and state files, so a second pass would lose the first one's answers or its pause. A lock older than LOCK_STALE_MS is
|
|
162
|
+
* left over from a crash and is taken over. */
|
|
163
|
+
const LOCK_STALE_MS = 10 * 60 * 1000;
|
|
164
|
+
const lockFile = (cacheDir) => path.join(cacheDir, 'jev-categorize.lock');
|
|
165
|
+
function takeLock(cacheDir) {
|
|
166
|
+
const f = lockFile(cacheDir);
|
|
167
|
+
try { fs.mkdirSync(cacheDir, { recursive: true }); } catch { /* the write below reports it */ }
|
|
168
|
+
for (let i = 0; i < 2; i++) {
|
|
169
|
+
try { fs.writeFileSync(f, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: 'wx' }); return true; }
|
|
170
|
+
catch (e) {
|
|
171
|
+
if (!e || e.code !== 'EEXIST') return true; // no lock possible here; the state writes fail the same way
|
|
172
|
+
const held = readJson(f, null);
|
|
173
|
+
if (held && typeof held.at === 'number' && Date.now() - held.at < LOCK_STALE_MS) return false;
|
|
174
|
+
try { fs.unlinkSync(f); } catch { /* another process took it over first */ }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
function dropLock(cacheDir) { try { fs.unlinkSync(lockFile(cacheDir)); } catch { /* already gone */ } }
|
|
180
|
+
const BUSY = 'Another pass is categorizing this repository now, so nothing was sent. Try again when it ends.';
|
|
181
|
+
async function locked(cacheDir, busy, fn) {
|
|
182
|
+
if (!takeLock(cacheDir)) return busy;
|
|
183
|
+
try { return await fn(); } finally { dropLock(cacheDir); }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const emptyState = () => ({ confirmed: false, confirmed_at: null, paused: null, rejected: 0, unreachable: 0, unreadable: 0, last: null });
|
|
187
|
+
function readState(cacheDir) {
|
|
188
|
+
const s = readJson(stateFile(cacheDir), null);
|
|
189
|
+
return s && typeof s === 'object' ? { ...emptyState(), ...s } : emptyState();
|
|
190
|
+
}
|
|
191
|
+
const writeState = (cacheDir, s) => writeJson(stateFile(cacheDir), s);
|
|
192
|
+
function readCache(cacheDir) { const c = readJson(cacheFile(cacheDir), {}); return c && typeof c === 'object' && !Array.isArray(c) ? c : {}; }
|
|
193
|
+
function writeCache(cacheDir, c) {
|
|
194
|
+
const keys = Object.keys(c);
|
|
195
|
+
if (keys.length > MAX_CACHE) for (const k of keys.sort((a, b) => String(c[a].at).localeCompare(String(c[b].at))).slice(0, keys.length - MAX_CACHE)) delete c[k];
|
|
196
|
+
return writeJson(cacheFile(cacheDir), c);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** What the page may know about the bridge for one repository. */
|
|
200
|
+
function status(cacheDir) {
|
|
201
|
+
const s = readState(cacheDir);
|
|
202
|
+
return { confirmed: s.confirmed, confirmed_at: s.confirmed_at, paused: s.paused, last: s.last };
|
|
203
|
+
}
|
|
204
|
+
/** The PM's press: lift a pause and forget the failure counts. */
|
|
205
|
+
function resume(cacheDir) {
|
|
206
|
+
const s = readState(cacheDir);
|
|
207
|
+
s.paused = null; s.rejected = 0; s.unreachable = 0; s.unreadable = 0;
|
|
208
|
+
return writeState(cacheDir, s);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Jev's cached answer for one locally undetermined document, or null. Reads only; sends nothing. */
|
|
212
|
+
function lookup(cache, doc) {
|
|
213
|
+
const hit = cache && cache[keyFor(snippetFor(doc))];
|
|
214
|
+
if (!hit || !hit.probs) return null; // an unreadable answer is cached as a marker, never shown
|
|
215
|
+
const d = decide(hit.probs);
|
|
216
|
+
return d ? { ...d, model: hit.model || null, sentence: sentenceFor(d) } : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Triage the candidates: excluded, withheld by the secret scan, answered from the cache, or to ask. Pure; sends nothing. */
|
|
220
|
+
function triage(cands, { exclude = [], cache }) {
|
|
221
|
+
const out = { ask: [], cached: 0, withheld: [], excluded: 0 };
|
|
222
|
+
for (const d of cands) {
|
|
223
|
+
if (settings.isExcluded(exclude, d.rel)) { out.excluded++; continue; }
|
|
224
|
+
const snip = snippetFor(d);
|
|
225
|
+
const rules = scanner.scan(snip);
|
|
226
|
+
if (rules.length) { out.withheld.push({ doc: d.rel, rules }); continue; }
|
|
227
|
+
const ck = keyFor(snip);
|
|
228
|
+
if (cache[ck]) out.cached++; else out.ask.push({ rel: d.rel, snip, ck });
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/* ── asking ────────────────────────────────────────────────────────────────────────────────────── */
|
|
234
|
+
async function askAll({ items, key, ask, askOpts, state, cache, now }) {
|
|
235
|
+
let failed = 0, unreadable = 0, sent = 0, decided = 0, unsure = 0, lastError = null;
|
|
236
|
+
const asked = new Set();
|
|
237
|
+
for (const it of items) {
|
|
238
|
+
if (state.paused) break;
|
|
239
|
+
if (asked.has(it.ck)) continue; // two files with one snippet: one request
|
|
240
|
+
asked.add(it.ck);
|
|
241
|
+
const req = requestFor(it.snip);
|
|
242
|
+
const res = await ask({ key, state: req.state, questions: req.questions, ...askOpts });
|
|
243
|
+
if (!res.ok) {
|
|
244
|
+
failed++; lastError = res.reason;
|
|
245
|
+
if (res.status === 401) { state.rejected++; state.unreachable = 0; } else { state.unreachable++; state.rejected = 0; }
|
|
246
|
+
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
|
+
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() };
|
|
248
|
+
continue; // not cached: asked again next time
|
|
249
|
+
}
|
|
250
|
+
state.rejected = 0; state.unreachable = 0; sent++;
|
|
251
|
+
const probs = readAnswer(res.answers && res.answers.category);
|
|
252
|
+
if (!probs) {
|
|
253
|
+
// Cached as a marker, so no later pass sends the same snippet again; three in a row pause the repository, because
|
|
254
|
+
// that is what a changed answer format looks like.
|
|
255
|
+
unreadable++; state.unreadable = (state.unreadable || 0) + 1;
|
|
256
|
+
cache[it.ck] = { probs: null, unreadable: true, model: res.model || null, at: new Date(now).toISOString() };
|
|
257
|
+
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
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
state.unreadable = 0;
|
|
261
|
+
cache[it.ck] = { probs, model: res.model || null, at: new Date(now).toISOString() };
|
|
262
|
+
if (decide(probs).decided) decided++; else unsure++;
|
|
263
|
+
}
|
|
264
|
+
return { failed, unreadable, sent, decided, unsure, lastError };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const summarize = (extra) => ({ at: new Date().toISOString(), sent: 0, cached: 0, withheld: [], excluded: 0, failed: 0, decided: 0, unsure: 0, ...extra });
|
|
268
|
+
const gatesFor = (key, flags) => gates({ key: typesafe.validKey(key), consent: !!(flags && flags.consent), categorize: !!(flags && flags.categorize) });
|
|
269
|
+
|
|
270
|
+
/** Which snippets a plan sends, as one hash, so a confirmation covers those files and not merely that count. */
|
|
271
|
+
const fingerprintOf = (ask) => sha(Array.from(new Set(ask.map((x) => x.ck))).sort().join('\n'));
|
|
272
|
+
|
|
273
|
+
/** What a confirmed run would send now. Sends nothing. */
|
|
274
|
+
function plan({ docs, named, cacheDir, exclude = [] }) {
|
|
275
|
+
const cands = candidates(docs, named);
|
|
276
|
+
const t = triage(cands, { exclude, cache: readCache(cacheDir) });
|
|
277
|
+
const unique = new Set(t.ask.map((x) => x.ck)).size;
|
|
278
|
+
return { candidates: cands.length, requests: unique, fingerprint: fingerprintOf(t.ask), characters: t.ask.reduce((n, x) => n + x.snip.length, 0), cached: t.cached,
|
|
279
|
+
withheld: t.withheld.slice(0, MAX_LISTED), withheld_count: t.withheld.length, excluded: t.excluded, host: typesafe.HOST, confirmed: readState(cacheDir).confirmed };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The PM's confirmed run. Refuses unless `expectRequests` is what the plan counts now, so the PM never confirms one plan
|
|
284
|
+
* and runs another. The first such run is what lets later passes send on their own.
|
|
285
|
+
*/
|
|
286
|
+
async function runConfirmed(opts) {
|
|
287
|
+
const g = gatesFor(opts.key, opts.flags);
|
|
288
|
+
if (!g.open) return { ok: false, error: gateSentence(g.missing), missing: g.missing };
|
|
289
|
+
return locked(opts.cacheDir, { ok: false, busy: true, error: BUSY }, () => confirmedPass(opts));
|
|
290
|
+
}
|
|
291
|
+
async function confirmedPass({ docs, named, cacheDir, key, exclude = [], expectRequests, expectFingerprint = null, ask = typesafe.ask, askOpts = {}, now = Date.now() }) {
|
|
292
|
+
const state = readState(cacheDir);
|
|
293
|
+
if (state.paused) return { ok: false, error: state.paused.reason + ' Resume it first.' };
|
|
294
|
+
const cache = readCache(cacheDir);
|
|
295
|
+
const t = triage(candidates(docs, named), { exclude, cache });
|
|
296
|
+
const unique = new Set(t.ask.map((x) => x.ck)).size;
|
|
297
|
+
if (!Number.isInteger(expectRequests) || expectRequests !== unique) {
|
|
298
|
+
return { ok: false, conflict: true, error: `the plan changed — it now has ${unique} request(s), not ${expectRequests}. Review it again.` };
|
|
299
|
+
}
|
|
300
|
+
if (expectFingerprint !== null && expectFingerprint !== fingerprintOf(t.ask)) {
|
|
301
|
+
return { ok: false, conflict: true, error: 'the plan changed — the same number of files, but not the same files. Review it again.' };
|
|
302
|
+
}
|
|
303
|
+
const r = await askAll({ items: t.ask, key, ask, askOpts, state, cache, now });
|
|
304
|
+
state.confirmed = true; state.confirmed_at = state.confirmed_at || new Date(now).toISOString();
|
|
305
|
+
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
|
+
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 };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The automatic pass, after a confirmed first run: only snippets with no cached answer, and at most AUTO_LIMIT of them.
|
|
312
|
+
* Does nothing unless all three gates hold.
|
|
313
|
+
*/
|
|
314
|
+
async function runAuto(opts) {
|
|
315
|
+
const g = gatesFor(opts.key, opts.flags);
|
|
316
|
+
if (!g.open) return { ok: true, skipped: 'gates', missing: g.missing, sent: 0 };
|
|
317
|
+
return locked(opts.cacheDir, { ok: true, skipped: 'busy', sent: 0 }, () => autoPass(opts));
|
|
318
|
+
}
|
|
319
|
+
async function autoPass({ docs, named, cacheDir, key, exclude = [], ask = typesafe.ask, askOpts = {}, now = Date.now() }) {
|
|
320
|
+
const state = readState(cacheDir);
|
|
321
|
+
if (state.paused) return { ok: true, skipped: 'paused', paused: state.paused, sent: 0 };
|
|
322
|
+
if (!state.confirmed) return { ok: true, skipped: 'not-confirmed', sent: 0 };
|
|
323
|
+
const cache = readCache(cacheDir);
|
|
324
|
+
const t = triage(candidates(docs, named), { exclude, cache });
|
|
325
|
+
const unique = new Set(t.ask.map((x) => x.ck)).size;
|
|
326
|
+
if (!unique) return { ok: true, skipped: 'nothing-new', sent: 0 };
|
|
327
|
+
if (unique > AUTO_LIMIT) {
|
|
328
|
+
state.last = summarize({ waiting: unique, cached: t.cached, withheld_count: t.withheld.length, excluded: t.excluded });
|
|
329
|
+
writeState(cacheDir, state);
|
|
330
|
+
return { ok: true, skipped: 'needs-confirmation', requests: unique, sent: 0 };
|
|
331
|
+
}
|
|
332
|
+
const r = await askAll({ items: t.ask, key, ask, askOpts, state, cache, now });
|
|
333
|
+
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
|
+
writeCache(cacheDir, cache); writeState(cacheDir, state);
|
|
335
|
+
return { ok: true, sent: r.sent, cached: t.cached, failed: r.failed, decided: r.decided, unsure: r.unsure, paused: state.paused };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/* ── the measured pass (Run 3): the labelled set through Jev, started by the PM's own press ──────────────────────────── */
|
|
339
|
+
/* It measures Jev, so it sends labelled files the local rules already decide, not only the undetermined ones (D82 point 11).
|
|
340
|
+
* A file the PM corrected is never sent. The gates, the exclusions, the secret scan and the confirmed count and files still
|
|
341
|
+
* hold. It reports agreement in the open, including when it is poor. */
|
|
342
|
+
function measureItems(rows, exclude) {
|
|
343
|
+
const items = []; const withheld = []; let excluded = 0; let corrected = 0;
|
|
344
|
+
for (const r of rows || []) {
|
|
345
|
+
const rel = String(r.rel || '').replace(/\\/g, '/');
|
|
346
|
+
if (r.corrected) { corrected++; continue; }
|
|
347
|
+
if (settings.isExcluded(exclude, rel)) { excluded++; continue; }
|
|
348
|
+
const snip = snippetFor(r);
|
|
349
|
+
const rules = scanner.scan(snip);
|
|
350
|
+
if (rules.length) { withheld.push({ doc: rel, rules }); continue; }
|
|
351
|
+
items.push({ rel, snip, ck: keyFor(snip), expected: r.expected });
|
|
352
|
+
}
|
|
353
|
+
return { items, withheld, excluded, corrected };
|
|
354
|
+
}
|
|
355
|
+
/** What the measured pass would send. Sends nothing. */
|
|
356
|
+
function measurePlan({ rows, exclude = [] }) {
|
|
357
|
+
const m = measureItems(rows, exclude);
|
|
358
|
+
const unique = new Set(m.items.map((x) => x.ck)).size;
|
|
359
|
+
return { labelled: (rows || []).length, requests: unique, fingerprint: fingerprintOf(m.items), characters: m.items.reduce((n, x) => n + x.snip.length, 0),
|
|
360
|
+
withheld_count: m.withheld.length, excluded: m.excluded, corrected: m.corrected, host: typesafe.HOST };
|
|
361
|
+
}
|
|
362
|
+
/** The measured pass. Refuses unless the gates hold and the PM confirmed this plan's count (and, when given, its files). */
|
|
363
|
+
async function measure(opts) {
|
|
364
|
+
const g = gatesFor(opts.key, opts.flags);
|
|
365
|
+
if (!g.open) return { ok: false, error: gateSentence(g.missing), missing: g.missing };
|
|
366
|
+
return locked(opts.cacheDir, { ok: false, busy: true, error: BUSY }, () => measuredPass(opts));
|
|
367
|
+
}
|
|
368
|
+
async function measuredPass({ rows, cacheDir, key, exclude = [], expectRequests, expectFingerprint = null, ask = typesafe.ask, askOpts = {}, now = Date.now() }) {
|
|
369
|
+
const state = readState(cacheDir);
|
|
370
|
+
if (state.paused) return { ok: false, error: state.paused.reason + ' Resume it first.' };
|
|
371
|
+
const m = measureItems(rows, exclude);
|
|
372
|
+
const unique = new Set(m.items.map((x) => x.ck)).size;
|
|
373
|
+
if (!Number.isInteger(expectRequests) || expectRequests !== unique || (expectFingerprint !== null && expectFingerprint !== fingerprintOf(m.items))) {
|
|
374
|
+
return { ok: false, conflict: true, error: `the plan changed — it now has ${unique} request(s), not ${expectRequests}. Review it again.` };
|
|
375
|
+
}
|
|
376
|
+
const cache = readCache(cacheDir);
|
|
377
|
+
const toAsk = m.items.filter((x) => !cache[x.ck]); // a cached answer is the same answer; not asked twice
|
|
378
|
+
const r = await askAll({ items: toAsk, key, ask, askOpts, state, cache, now });
|
|
379
|
+
writeCache(cacheDir, cache); writeState(cacheDir, state);
|
|
380
|
+
const per = {}; const disagreements = []; let right = 0, answered = 0, unsure = 0, unreadable = 0, missing = 0;
|
|
381
|
+
for (const it of m.items) {
|
|
382
|
+
const hit = cache[it.ck];
|
|
383
|
+
if (!hit) { missing++; continue; }
|
|
384
|
+
if (!hit.probs) { unreadable++; continue; }
|
|
385
|
+
const d = decide(hit.probs);
|
|
386
|
+
const row = per[it.expected] || (per[it.expected] = { labelled: 0, right: 0, unsure: 0 });
|
|
387
|
+
row.labelled++; answered++;
|
|
388
|
+
if (!d.decided) { unsure++; row.unsure++; disagreements.push({ doc: it.rel, expected: it.expected, got: 'undetermined', leading: d.leading.map((x) => x.category) }); continue; }
|
|
389
|
+
if (d.category === it.expected) { right++; row.right++; } else disagreements.push({ doc: it.rel, expected: it.expected, got: d.category, words: wordsFor(d.p) });
|
|
390
|
+
}
|
|
391
|
+
return { ok: true, sent: r.sent, failed: r.failed, paused: state.paused, labelled: (rows || []).length, answered, right, unsure, unreadable, not_answered: missing,
|
|
392
|
+
agreement: answered ? right / answered : null, by_category: per, disagreements, withheld_count: m.withheld.length, excluded: m.excluded, corrected: m.corrected };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
module.exports = {
|
|
396
|
+
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,
|
|
399
|
+
};
|
|
@@ -36,11 +36,14 @@ const REJECTED_LIMIT = 2;
|
|
|
36
36
|
const UNREACHABLE_LIMIT = 3;
|
|
37
37
|
const sha = (s) => crypto.createHash('sha1').update(s).digest('hex');
|
|
38
38
|
|
|
39
|
+
const STATUS_KINDS = new Set(['stub', 'contradicts']);
|
|
39
40
|
const QUESTION_LABEL = {
|
|
40
41
|
coverage: ['REQUIREMENT', 'RUBRIC DIMENSIONS'],
|
|
41
42
|
delivers: ['STORY SECTION', 'REQUIREMENTS IT CITES'],
|
|
42
43
|
supports: ['SENTENCE THAT CITES A DECISION', 'THE DECISION'],
|
|
43
44
|
sections: ['FIRST SECTION', 'SECOND SECTION'],
|
|
45
|
+
stub: ['DOCUMENT', 'THE INITIATIVE IT BELONGS TO'],
|
|
46
|
+
contradicts: ['NOTE', 'REQUIREMENTS OF THE INITIATIVE IT NAMES'],
|
|
44
47
|
};
|
|
45
48
|
|
|
46
49
|
/** Words, never the number. */
|
|
@@ -71,7 +74,7 @@ function writeJson(f, v) {
|
|
|
71
74
|
}
|
|
72
75
|
const stateFile = (cacheDir) => path.join(cacheDir, 'jev.json');
|
|
73
76
|
const cacheFile = (cacheDir) => path.join(cacheDir, 'jev-cache.json');
|
|
74
|
-
const emptyState = () => ({ baselined: false, baselined_at: null, pairs: {}, pending: 0, paused: null, rejected: 0, unreachable: 0, last: null });
|
|
77
|
+
const emptyState = () => ({ status_baselined: false, status_ids: [], baselined: false, baselined_at: null, pairs: {}, pending: 0, paused: null, rejected: 0, unreachable: 0, last: null });
|
|
75
78
|
function readState(cacheDir) {
|
|
76
79
|
const s = readJson(stateFile(cacheDir), null);
|
|
77
80
|
if (!s || typeof s !== 'object') return emptyState();
|
|
@@ -88,7 +91,7 @@ function writeCache(cacheDir, c) {
|
|
|
88
91
|
/** What the page may know about the bridge for one repository. */
|
|
89
92
|
function status(cacheDir) {
|
|
90
93
|
const s = readState(cacheDir);
|
|
91
|
-
return { baselined: s.baselined, baselined_at: s.baselined_at, paused: s.paused, last: s.last, tracked: Object.keys(s.pairs).length };
|
|
94
|
+
return { baselined: s.baselined, baselined_at: s.baselined_at, paused: s.paused, last: s.last, tracked: Object.keys(s.pairs).length, status_baselined: !!s.status_baselined };
|
|
92
95
|
}
|
|
93
96
|
/** The PM's press: lift a pause and forget the failure counts. */
|
|
94
97
|
function resume(cacheDir) {
|
|
@@ -98,7 +101,9 @@ function resume(cacheDir) {
|
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
/* ── one pair: what would happen to it ─────────────────────────────────────────────────────────── */
|
|
101
|
-
|
|
104
|
+
// The cache key names the model REQUESTED. The caller passes a named version from the model policy
|
|
105
|
+
// (prompt-model-routing R11), so a change of version misses the cache and a revert to a version hits it again.
|
|
106
|
+
const hashes = (p, model = typesafe.DEFAULT_MODEL) => { const hA = sha(p.a), hB = sha(p.b); return { hA, hB, h: sha(hA + '\0' + hB), ck: sha([hA, hB, p.kind, model].join('\0')) }; };
|
|
102
107
|
const requestFor = (p) => {
|
|
103
108
|
const [la, lb] = QUESTION_LABEL[p.kind];
|
|
104
109
|
return { state: `${la}:\n${p.a}\n\n${lb}:\n${p.b}`, questions: { [p.kind]: { type: 'noul', instructions: p.question } } };
|
|
@@ -109,15 +114,17 @@ function sentenceFor(p, p1) {
|
|
|
109
114
|
case 'coverage': return `Jev rates it ${w} that ${p.a_label} in ${p.doc} is graded by no dimension of ${p.partner}.`;
|
|
110
115
|
case 'delivers': return `Jev rates it ${w} that the story section "${p.subject}" in ${p.doc} does not deliver what ${(p.ids || []).join(', ')} in ${p.partner} asks for.`;
|
|
111
116
|
case 'supports': return `Jev rates it ${w} that ${p.partner} does not support what ${p.doc} says it decides in the sentence that cites it.`;
|
|
117
|
+
case 'stub': return `Jev rates it ${w} that ${p.doc} is a stub, an outline or an unfinished draft.`;
|
|
118
|
+
case 'contradicts': return `Jev rates it ${w} that ${p.doc} says something that contradicts a requirement in ${p.partner}.`;
|
|
112
119
|
default: return `Jev rates it ${w} that the ${p.a_label} sections of ${p.doc} contradict each other.`;
|
|
113
120
|
}
|
|
114
121
|
}
|
|
115
122
|
|
|
116
123
|
/** Triage every pair: excluded, withheld by the secret scan, answered from the cache, or to ask. Pure; sends nothing. */
|
|
117
|
-
function triage(pairs, { exclude, cache, state, onlyChanged }) {
|
|
124
|
+
function triage(pairs, { exclude, cache, state, onlyChanged, model }) {
|
|
118
125
|
const out = { ask: [], cached: [], withheld: [], excluded: 0, unchanged: 0 };
|
|
119
126
|
for (const p of pairs) {
|
|
120
|
-
const hs = hashes(p);
|
|
127
|
+
const hs = hashes(p, model);
|
|
121
128
|
if (onlyChanged && state.pairs[p.id] === hs.h) { out.unchanged++; continue; }
|
|
122
129
|
if (settings.isExcluded(exclude, p.doc) || (p.partner && /\.\w+$/.test(p.partner) && settings.isExcluded(exclude, p.partner))) { out.excluded++; continue; }
|
|
123
130
|
const rules = Array.from(new Set(scanner.scan(p.a).concat(scanner.scan(p.b))));
|
|
@@ -143,7 +150,7 @@ function recordResults({ ledgerFile, pairs, evaluated, now }) {
|
|
|
143
150
|
if (verdict.p >= 0.5) {
|
|
144
151
|
const sentence = sentenceFor(p, verdict.p); const likelihood = wordsFor(verdict.p);
|
|
145
152
|
if (prev && prev.status === 'suspected' && prev.likelihood === likelihood && prev.text === p.a.slice(0, 220) && prev.sentence === sentence) continue;
|
|
146
|
-
append({ type: 'claim', claim_id: id, doc: p.doc, doc_type: 'PRD', kind: 'jev_' + p.kind, subject: p.subject, text: p.a.slice(0, 220), status: 'suspected',
|
|
153
|
+
append({ type: 'claim', claim_id: id, doc: p.doc, doc_type: p.doc_type || 'PRD', kind: 'jev_' + p.kind, initiative: p.initiative || undefined, subject: p.subject, text: p.a.slice(0, 220), status: 'suspected',
|
|
147
154
|
probability: Math.round(verdict.p * 1000) / 1000, likelihood, truth: null, source: p.partner || null, sentence, model: verdict.model || null, verified_at: at, at });
|
|
148
155
|
} else if (prev && prev.status === 'suspected') {
|
|
149
156
|
append({ ...prev, status: 'withdrawn', sentence: null, verified_at: at, at });
|
|
@@ -196,17 +203,34 @@ async function run({ root, cacheDir, ledgerFile, key, flags, exclude = [], chang
|
|
|
196
203
|
if (!g.open) return { ok: true, skipped: 'gates', missing: g.missing, sent: 0 };
|
|
197
204
|
const state = readState(cacheDir);
|
|
198
205
|
if (state.paused) return { ok: true, skipped: 'paused', paused: state.paused, sent: 0 };
|
|
199
|
-
|
|
200
|
-
|
|
206
|
+
const statusOn = !!(flags && flags.status_read);
|
|
207
|
+
// A flip of the status switch is work even when no file changed: its baseline is recorded (or forgotten) now.
|
|
208
|
+
if (state.baselined && changed === 0 && !state.pending && statusOn === !!state.status_baselined) return { ok: true, skipped: 'nothing-changed', sent: 0 };
|
|
209
|
+
const { pairs } = claims.pairsFor({ root, cacheDir, statusKinds: statusOn });
|
|
201
210
|
if (!state.baselined) {
|
|
202
211
|
for (const p of pairs) state.pairs[p.id] = hashes(p).h;
|
|
203
212
|
state.baselined = true; state.baselined_at = new Date(now).toISOString();
|
|
213
|
+
if (statusOn) { state.status_baselined = true; state.status_ids = pairs.filter((p) => STATUS_KINDS.has(p.kind)).map((p) => p.id); }
|
|
204
214
|
state.last = summarize({ baseline: pairs.length });
|
|
205
215
|
writeState(cacheDir, state);
|
|
206
216
|
return { ok: true, baseline: pairs.length, sent: 0 };
|
|
207
217
|
}
|
|
218
|
+
// D83: the status kinds have their own baseline. Switching "read documents for status" on records the documents that
|
|
219
|
+
// exist and sends nothing for them, exactly as D80's first pass does; switching it off forgets that baseline, so a
|
|
220
|
+
// later switch-on starts again from what exists then, never from what changed while it was off.
|
|
221
|
+
if (!statusOn && state.status_baselined) {
|
|
222
|
+
for (const id of state.status_ids || []) delete state.pairs[id];
|
|
223
|
+
state.status_baselined = false; state.status_ids = [];
|
|
224
|
+
}
|
|
225
|
+
if (statusOn && !state.status_baselined) {
|
|
226
|
+
const ids = [];
|
|
227
|
+
for (const p of pairs) if (STATUS_KINDS.has(p.kind)) { state.pairs[p.id] = hashes(p).h; ids.push(p.id); }
|
|
228
|
+
state.status_baselined = true; state.status_ids = ids;
|
|
229
|
+
}
|
|
230
|
+
// every status pair seen while the switch is on is remembered, so switching off forgets the ones added later too
|
|
231
|
+
if (statusOn) state.status_ids = Array.from(new Set((state.status_ids || []).concat(pairs.filter((p) => STATUS_KINDS.has(p.kind)).map((p) => p.id))));
|
|
208
232
|
const cache = readCache(cacheDir);
|
|
209
|
-
const t = triage(pairs, { exclude, cache, state, onlyChanged: true });
|
|
233
|
+
const t = triage(pairs, { exclude, cache, state, onlyChanged: true, model: askOpts.model || typesafe.DEFAULT_MODEL });
|
|
210
234
|
const evaluated = t.cached.map((c) => [c.p, c.verdict]);
|
|
211
235
|
for (const c of t.cached) state.pairs[c.p.id] = c.hs.h;
|
|
212
236
|
const r = await askAll({ items: t.ask, key, ask, askOpts, state, cache, now });
|
|
@@ -228,9 +252,9 @@ async function run({ root, cacheDir, ledgerFile, key, flags, exclude = [], chang
|
|
|
228
252
|
}
|
|
229
253
|
|
|
230
254
|
/* ── the backlog: every pair not already answered, after the PM has seen the count ─────────────── */
|
|
231
|
-
function backlogPlan({ root, cacheDir, exclude = [] }) {
|
|
232
|
-
const { pairs } = claims.pairsFor({ root, cacheDir });
|
|
233
|
-
const t = triage(pairs, { exclude, cache: readCache(cacheDir), state: readState(cacheDir), onlyChanged: false });
|
|
255
|
+
function backlogPlan({ root, cacheDir, exclude = [], model = typesafe.DEFAULT_MODEL, flags = null }) {
|
|
256
|
+
const { pairs } = claims.pairsFor({ root, cacheDir, statusKinds: !!(flags && flags.status_read) });
|
|
257
|
+
const t = triage(pairs, { exclude, cache: readCache(cacheDir), state: readState(cacheDir), onlyChanged: false, model });
|
|
234
258
|
const reqs = t.ask.map((x) => requestFor(x.p));
|
|
235
259
|
return { pairs: pairs.length, requests: reqs.length, characters: reqs.reduce((n, r) => n + r.state.length, 0), cached: t.cached.length, withheld: t.withheld.slice(0, MAX_LISTED), withheld_count: t.withheld.length, excluded: t.excluded, host: typesafe.HOST };
|
|
236
260
|
}
|
|
@@ -241,9 +265,9 @@ async function runBacklog({ root, cacheDir, ledgerFile, key, flags, exclude = []
|
|
|
241
265
|
if (!g.open) return { ok: false, error: gateSentence(g.missing) };
|
|
242
266
|
const state = readState(cacheDir);
|
|
243
267
|
if (state.paused) return { ok: false, error: state.paused.reason + ' Resume it first.' };
|
|
244
|
-
const { pairs } = claims.pairsFor({ root, cacheDir });
|
|
268
|
+
const { pairs } = claims.pairsFor({ root, cacheDir, statusKinds: !!(flags && flags.status_read) });
|
|
245
269
|
const cache = readCache(cacheDir);
|
|
246
|
-
const t = triage(pairs, { exclude, cache, state, onlyChanged: false });
|
|
270
|
+
const t = triage(pairs, { exclude, cache, state, onlyChanged: false, model: askOpts.model || typesafe.DEFAULT_MODEL });
|
|
247
271
|
if (!Number.isInteger(expectRequests) || expectRequests !== t.ask.length) {
|
|
248
272
|
return { ok: false, conflict: true, error: `the plan changed — it now has ${t.ask.length} request(s), not ${expectRequests}. Review it again.` };
|
|
249
273
|
}
|
|
@@ -263,4 +287,24 @@ async function runBacklog({ root, cacheDir, ledgerFile, key, flags, exclude = []
|
|
|
263
287
|
return { ok: true, sent: r.sent, cached: t.cached.length, withheld: t.withheld.length, excluded: t.excluded, failed: r.failed, suspected, written, paused: state.paused };
|
|
264
288
|
}
|
|
265
289
|
|
|
266
|
-
|
|
290
|
+
/**
|
|
291
|
+
* status-from-artifacts Run 2 (D83): the suggestions for ONE initiative, read from the claims ledger — suspected
|
|
292
|
+
* `jev_stub` and `jev_contradicts` claims on the documents tied to it. Read-only. A suggestion is advisory: the caller
|
|
293
|
+
* shows it beside the ladder and never lets it change a step, a stage or a count.
|
|
294
|
+
*/
|
|
295
|
+
function suggestionsFor({ ledgerFile, initiativeId }) {
|
|
296
|
+
const out = [];
|
|
297
|
+
let folded;
|
|
298
|
+
try { folded = claims.foldLedger(ledgerFile); } catch { return { ok: false, reason: 'the claims ledger could not be read', items: [] }; }
|
|
299
|
+
for (const [, e] of folded) {
|
|
300
|
+
if (!e || e.status !== 'suspected') continue;
|
|
301
|
+
if (e.kind !== 'jev_stub' && e.kind !== 'jev_contradicts') continue;
|
|
302
|
+
const iid = e.initiative || String(e.subject || '').split(':')[1];
|
|
303
|
+
if (iid !== initiativeId) continue;
|
|
304
|
+
out.push({ kind: e.kind.slice(4), doc: e.doc, partner: e.source || null, likelihood: e.likelihood || null, sentence: e.sentence || null, model: e.model || null, at: e.verified_at || e.at || null });
|
|
305
|
+
}
|
|
306
|
+
out.sort((a, b) => String(a.doc).localeCompare(String(b.doc)));
|
|
307
|
+
return { ok: true, items: out.slice(0, MAX_LISTED) };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
module.exports = { suggestionsFor, run, runBacklog, backlogPlan, status, resume, gates, gateSentence, wordsFor, triage, requestFor, sentenceFor, REJECTED_LIMIT, UNREACHABLE_LIMIT };
|