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
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* node pmos-model.js recommend "the work" [--skill x] [--agent claude]
|
|
8
8
|
* node pmos-model.js resolve [--skill x] [--tier fast|balanced|deep] [--agent claude]
|
|
9
9
|
* node pmos-model.js buckets evaluator models seen in evals.jsonl (calibration is per model, D40)
|
|
10
|
+
* node pmos-model.js route "the prompt" [--skill x] [--source pm|agent|schedule] [--explain]
|
|
11
|
+
* route ANY prompt (prompt-model-routing): rules, then keyword routes. The Jev
|
|
12
|
+
* classification stage runs only in the workspace bridge, which holds the key.
|
|
13
|
+
* node pmos-model.js bakeoff --labels <file> replay PM-labelled prompts through each router and both baselines (R8βR10)
|
|
10
14
|
*
|
|
11
15
|
* PMOS routes on TIERS, never on a model name: the PM maps a tier to a model and an effort in
|
|
12
16
|
* <state>/models/policy.json, and the defaults use the harness's own aliases (haiku, sonnet, opus) so they
|
|
@@ -37,6 +41,7 @@ const DEFAULT_POLICY = {
|
|
|
37
41
|
{ match: { skill: 'backlog-triage' }, tier: 'fast', why: 'sorting, not deciding' },
|
|
38
42
|
{ match: { skill: 'skill-discover' }, tier: 'balanced', why: 'judging relevance and safety of text found on the web' },
|
|
39
43
|
{ match: { skill: 'skill-generate' }, tier: 'balanced', why: 'drafting a skill from evidence in the repository' },
|
|
44
|
+
{ match: { skill: 'live-notes' }, tier: 'balanced', why: 'placing talking points and writing short cited notes from the initiatives\' own text' },
|
|
40
45
|
{ match: { skill: 'gather-requirements' }, tier: 'balanced', why: 'reading many sources and judging what is a requirement' },
|
|
41
46
|
{ match: { skill: 'report-weekly' }, tier: 'balanced', why: 'a weekly synthesis' },
|
|
42
47
|
{ match: { skill: 'report-biweekly' }, tier: 'balanced', why: 'a two-week synthesis' },
|
|
@@ -80,6 +85,8 @@ function validate(p) {
|
|
|
80
85
|
if (!r.match || (typeof r.match.skill !== 'string' && !Array.isArray(r.match.words))) errs.push(`route ${i}: match needs a skill or a list of words`);
|
|
81
86
|
if (r.pinned !== undefined && typeof r.pinned !== 'boolean') errs.push(`route ${i}: pinned must be true or false`);
|
|
82
87
|
});
|
|
88
|
+
if (p.version !== undefined && ![1, 2].includes(p.version)) errs.push('`version` must be 1 or 2');
|
|
89
|
+
if (p.router !== undefined) errs.push(...validateRouter(p.router));
|
|
83
90
|
return errs;
|
|
84
91
|
}
|
|
85
92
|
function loadPolicy(root) {
|
|
@@ -198,10 +205,204 @@ function evaluatorNote(root, model) {
|
|
|
198
205
|
return b[m] ? null : `'${m}' has no recorded evaluator verdicts here: its calibration starts at zero and the Review Gate stays advisory for it (D40).`;
|
|
199
206
|
}
|
|
200
207
|
|
|
208
|
+
// ββ the prompt router (prompt-model-routing) βββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
209
|
+
/* A prompt gets a tier from the first stage that can decide it, and the answer NAMES that stage:
|
|
210
|
+
* rules an explicit choice, a schedule or skill tier, a pinned route, a long prompt
|
|
211
|
+
* jev a classification the workspace bridge obtained from TypeSafe under its gates, handed in as data
|
|
212
|
+
* keywords the D75 routes, unchanged
|
|
213
|
+
* This module makes no request. A classification arrives as an argument; without one the answer is `keywords`.
|
|
214
|
+
* Doubt goes UP a tier, never down; a pinned route is raised, never lowered. In a running session the answer
|
|
215
|
+
* is only a suggestion; spawn applies it as D75 Β§3 says. */
|
|
216
|
+
const ROUTER_DEFAULTS = {
|
|
217
|
+
enabled: true, // the keyword/rule router is always available; this only gates the Jev stage
|
|
218
|
+
shadow: false, // true: compute and log, show nothing, apply nothing
|
|
219
|
+
jev_model: 'jev-1.13.0', // a NAMED version from the vendor's models page, never the alias β a version change must miss the cache
|
|
220
|
+
confidence_floor: 0.6, // below this the tier answer is doubtful and goes up one tier
|
|
221
|
+
stakes_threshold: 0.5, // P(a wrong answer is costly) at or above this sets `deep`
|
|
222
|
+
closed_threshold: 0.65, // P(closed-set sorting task) at or above this may allow `fast`
|
|
223
|
+
max_prompt_chars: 6000, // what the bridge may send; longer prompts are truncated first
|
|
224
|
+
long_prompt_chars: 8000, // a prompt this long is never routed below `balanced`
|
|
225
|
+
min_labels: 50, // the bakeoff says "unmeasured" below this many labelled prompts (R9)
|
|
226
|
+
max_under_rate: 0.05, // a router may be applied automatically only at or below this under-routing rate (R9)
|
|
227
|
+
rates: null, // optional { fast, balanced, deep }: the PM's own cost per 1000 tokens; absent, cost_rank is used
|
|
228
|
+
jev_rate: null, // optional: the PM's own cost per 1000 Jev tokens, so the router's cost counts against its savings
|
|
229
|
+
};
|
|
230
|
+
const ROUTER_KEYS = Object.keys(ROUTER_DEFAULTS);
|
|
231
|
+
function validateRouter(r) {
|
|
232
|
+
if (!r || typeof r !== 'object' || Array.isArray(r)) return ['`router` must be an object'];
|
|
233
|
+
const errs = [];
|
|
234
|
+
for (const k of Object.keys(r)) if (!ROUTER_KEYS.includes(k)) errs.push(`router: unknown key '${k}'`);
|
|
235
|
+
for (const k of ['enabled', 'shadow']) if (r[k] !== undefined && typeof r[k] !== 'boolean') errs.push(`router.${k} must be true or false`);
|
|
236
|
+
if (r.jev_model !== undefined && (typeof r.jev_model !== 'string' || !MODEL_RE.test(r.jev_model))) errs.push('router.jev_model must be a plain version token like "jev-1.13.0"');
|
|
237
|
+
for (const k of ['confidence_floor', 'stakes_threshold', 'closed_threshold']) if (r[k] !== undefined && !(Number.isFinite(r[k]) && r[k] >= 0 && r[k] <= 1)) errs.push(`router.${k} must be a number from 0 to 1`);
|
|
238
|
+
if (r.min_labels !== undefined && !(Number.isInteger(r.min_labels) && r.min_labels >= 1 && r.min_labels <= 100000)) errs.push('router.min_labels must be a whole number of at least 1');
|
|
239
|
+
if (r.max_under_rate !== undefined && !(Number.isFinite(r.max_under_rate) && r.max_under_rate >= 0 && r.max_under_rate <= 1)) errs.push('router.max_under_rate must be a number from 0 to 1');
|
|
240
|
+
if (r.rates !== undefined && r.rates !== null && !(typeof r.rates === 'object' && !Array.isArray(r.rates) && TIERS.every((t) => Number.isFinite(r.rates[t]) && r.rates[t] >= 0) && Object.keys(r.rates).every((k) => TIERS.includes(k)))) errs.push('router.rates must give a number of 0 or more for each of fast, balanced and deep');
|
|
241
|
+
if (r.jev_rate !== undefined && r.jev_rate !== null && !(Number.isFinite(r.jev_rate) && r.jev_rate >= 0)) errs.push('router.jev_rate must be a number of 0 or more');
|
|
242
|
+
for (const k of ['max_prompt_chars', 'long_prompt_chars']) if (r[k] !== undefined && !(Number.isInteger(r[k]) && r[k] >= 200 && r[k] <= 60000)) errs.push(`router.${k} must be a whole number from 200 to 60000`);
|
|
243
|
+
return errs;
|
|
244
|
+
}
|
|
245
|
+
function routerConfig(policy) { return { ...ROUTER_DEFAULTS, ...((policy && policy.router) || {}) }; }
|
|
246
|
+
|
|
247
|
+
const up = (t) => TIERS[Math.min(TIERS.indexOf(t) + 1, TIERS.length - 1)];
|
|
248
|
+
const higher = (a, b) => (TIERS.indexOf(a) >= TIERS.indexOf(b) ? a : b);
|
|
249
|
+
const sha = (t) => require('crypto').createHash('sha256').update(String(t)).digest('hex');
|
|
250
|
+
|
|
251
|
+
/* classification (optional, from the bridge): { a:{tier,p}, b:{tier,p}, stakes:p, closed:p, model_asked, model_answered, tokens_in, tokens_out }
|
|
252
|
+
* a and b are the same tier question asked in two phrasings; p is the probability of the tier given. */
|
|
253
|
+
function route(root, prompt, ctx = {}, classification = null) {
|
|
254
|
+
const text = String(prompt == null ? '' : prompt);
|
|
255
|
+
if (!text.trim() && !ctx.skill) return { ok: false, error: 'say what the prompt is, or name a skill' };
|
|
256
|
+
const loaded = loadPolicy(root);
|
|
257
|
+
if (!loaded.ok) return { ok: false, error: loaded.error };
|
|
258
|
+
const policy = loaded.policy; const cfg = routerConfig(policy);
|
|
259
|
+
const source = ['pm', 'agent', 'schedule'].includes(ctx.source) ? ctx.source : 'agent';
|
|
260
|
+
const kw = routeFor(policy, { skill: ctx.skill, text });
|
|
261
|
+
const done = (tier, stage, confidence, why, extra = {}) => {
|
|
262
|
+
const x = policy.tiers[tier];
|
|
263
|
+
return { ok: true, engine: 'claude', tier, model: x.model, effort: x.effort, confidence, stage, why, source, pinned: !!(kw && kw.pinned),
|
|
264
|
+
shadow: !!cfg.shadow, applies: ctx.running ? 'suggestion β the PM presses to switch' : 'at spawn', policy_source: loaded.source,
|
|
265
|
+
prompt_hash: sha(text), prompt_len: text.length, ...extra };
|
|
266
|
+
};
|
|
267
|
+
// stage 1 β rules
|
|
268
|
+
const ex = ctx.explicit || {};
|
|
269
|
+
const ruled = ex.tier || ctx.scheduleTier || ctx.skillTier;
|
|
270
|
+
let floor = null; let floorWhy = '';
|
|
271
|
+
if (kw && kw.pinned) { floor = kw.tier; floorWhy = 'review work is pinned (D40)'; }
|
|
272
|
+
if (text.length >= cfg.long_prompt_chars) { floor = floor ? higher(floor, 'balanced') : 'balanced'; floorWhy = floorWhy || 'a long prompt is never routed below balanced'; }
|
|
273
|
+
if (ruled) {
|
|
274
|
+
const t = ex.tier || ctx.scheduleTier || ctx.skillTier;
|
|
275
|
+
if (!TIERS.includes(t)) return { ok: false, error: `'${t}' is not a tier β one of ${TIERS.join(', ')}` };
|
|
276
|
+
if (kw && kw.pinned && TIERS.indexOf(t) < TIERS.indexOf(kw.tier)) return { ok: false, error: `'${ctx.skill || 'this work'}' is pinned to the ${kw.tier} tier (D40) β ${t} would lower it` };
|
|
277
|
+
return done(t, 'rules', 'rule', ex.tier ? 'a tier was chosen for this prompt' : ctx.scheduleTier ? "the schedule's tier" : "the skill's tier");
|
|
278
|
+
}
|
|
279
|
+
if (kw && kw.pinned) return done(kw.tier, 'rules', 'rule', kw.why || 'a pinned route');
|
|
280
|
+
// stage 2 β the Jev classification, when the bridge supplied one
|
|
281
|
+
if (cfg.enabled && classification && classification.a && classification.b && TIERS.includes(classification.a.tier) && TIERS.includes(classification.b.tier)) {
|
|
282
|
+
const c = classification; const signals = { a: c.a, b: c.b, stakes: c.stakes, closed: c.closed };
|
|
283
|
+
const agree = c.a.tier === c.b.tier;
|
|
284
|
+
let tier = agree ? c.a.tier : up(higher(c.a.tier, c.b.tier));
|
|
285
|
+
const lowP = Math.min(Number(c.a.p) || 0, Number(c.b.p) || 0);
|
|
286
|
+
let confidence = 'high'; const notes = [];
|
|
287
|
+
if (!agree) { confidence = 'unsure'; notes.push('the two phrasings of the tier question disagreed, so it goes up one tier'); }
|
|
288
|
+
else if (lowP < cfg.confidence_floor) { confidence = 'unsure'; tier = up(tier); notes.push(`the tier answer was below the confidence floor (${cfg.confidence_floor}), so it goes up one tier`); }
|
|
289
|
+
if (Number(c.stakes) >= cfg.stakes_threshold) { tier = 'deep'; notes.push('a wrong answer here could change a decision, a spec, a security rule or something irreversible'); }
|
|
290
|
+
const closedSet = Number(c.closed) >= cfg.closed_threshold && !(Number(c.stakes) >= cfg.stakes_threshold);
|
|
291
|
+
if (floor) tier = higher(tier, floor);
|
|
292
|
+
return done(tier, 'jev', confidence, notes.length ? notes.join('; ') : 'Jev classified the prompt and both phrasings agreed', { signals, closed_set: closedSet,
|
|
293
|
+
jev_model_asked: c.model_asked || null, jev_model_answered: c.model_answered || null, tokens_in: c.tokens_in ?? null, tokens_out: c.tokens_out ?? null });
|
|
294
|
+
}
|
|
295
|
+
// stage 3 β the D75 keyword routes, unchanged
|
|
296
|
+
let tier = kw ? kw.tier : 'balanced';
|
|
297
|
+
if (floor) tier = higher(tier, floor);
|
|
298
|
+
return done(tier, 'keywords', kw ? 'rule' : 'unsure', kw ? (kw.why || 'a keyword route matched') : 'no rule matched β balanced is the safe middle; say what is at stake if it should be deep', { matched: !!kw });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
/* ββ the bakeoff (prompt-model-routing R8βR10) βββββββββββββββββββββββββββββββββββββββββββββββ
|
|
303
|
+
* Replays a labelled prompt set through each router and two baselines. A LABEL is the cheapest tier whose output the
|
|
304
|
+
* PM accepted when reading the outputs of every tier blind. A row: { prompt, label, tokens?, classification?, router_ms? }.
|
|
305
|
+
* prompt the text, so the keyword routes can run (the file is the PM's, kept where the PM chooses)
|
|
306
|
+
* label fast | balanced | deep
|
|
307
|
+
* tokens one number, or { fast, balanced, deep }: what the work cost at each tier; absent, 1000
|
|
308
|
+
* classification a recorded Jev classification (as route() takes it); absent, the Jev router has no answer for the row
|
|
309
|
+
* router_ms the recorded delay of that classification
|
|
310
|
+
* Under-routing (a tier BELOW the label) is the costly mistake: the PM gets work they would not accept. Nothing here
|
|
311
|
+
* makes a request or fetches a price; a cost is tokens times the PM's own rate, or the tier's cost_rank. */
|
|
312
|
+
const ROUTERS = ['keywords', 'jev', 'always-balanced', 'always-deep'];
|
|
313
|
+
function readLabels(file) {
|
|
314
|
+
let raw; try { raw = fs.readFileSync(file, 'utf8'); } catch (e) { return { ok: false, error: `the labelled set could not be read β ${String((e && e.code) || e)}` }; }
|
|
315
|
+
const rows = []; const bad = [];
|
|
316
|
+
raw.split('\n').forEach((l, i) => {
|
|
317
|
+
if (!l.trim()) return;
|
|
318
|
+
let o; try { o = JSON.parse(l); } catch { bad.push(i + 1); return; }
|
|
319
|
+
if (!o || typeof o.prompt !== 'string' || !TIERS.includes(o.label)) { bad.push(i + 1); return; }
|
|
320
|
+
rows.push(o);
|
|
321
|
+
});
|
|
322
|
+
if (bad.length) return { ok: false, error: `line(s) ${bad.slice(0, 10).join(', ')} are not { "prompt": text, "label": fast|balanced|deep }` };
|
|
323
|
+
return { ok: true, rows };
|
|
324
|
+
}
|
|
325
|
+
const pct = (xs, q) => { if (!xs.length) return null; const s = xs.slice().sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.ceil(q * s.length) - 1)]; };
|
|
326
|
+
function bakeoff(root, rows) {
|
|
327
|
+
const loaded = loadPolicy(root); if (!loaded.ok) return { ok: false, error: loaded.error };
|
|
328
|
+
const policy = loaded.policy; const cfg = routerConfig(policy);
|
|
329
|
+
const unit = cfg.rates ? 'the PM\'s rates per 1000 tokens' : 'cost_rank Γ tokens / 1000 (no rates set)';
|
|
330
|
+
const rate = (t) => (cfg.rates ? cfg.rates[t] : rank(policy, t));
|
|
331
|
+
const tokensAt = (row, t) => (row.tokens && typeof row.tokens === 'object' ? Number(row.tokens[t]) : Number(row.tokens)) || 1000;
|
|
332
|
+
const costOf = (row, t) => (tokensAt(row, t) / 1000) * rate(t);
|
|
333
|
+
const ix = (t) => TIERS.indexOf(t);
|
|
334
|
+
const pick = (name, row) => {
|
|
335
|
+
if (name === 'always-balanced') return { tier: 'balanced', ms: 0 };
|
|
336
|
+
if (name === 'always-deep') return { tier: 'deep', ms: 0 };
|
|
337
|
+
if (name === 'keywords') { const r = route(root, row.prompt, {}, null); return r.ok ? { tier: r.tier, ms: 0 } : null; }
|
|
338
|
+
if (!row.classification) return null;
|
|
339
|
+
const r = route(root, row.prompt, {}, row.classification);
|
|
340
|
+
if (!r.ok || r.stage !== 'jev') return null;
|
|
341
|
+
const jt = (Number(row.classification.tokens_in) || 0) + (Number(row.classification.tokens_out) || 0);
|
|
342
|
+
return { tier: r.tier, ms: Number.isFinite(row.router_ms) ? row.router_ms : null, routerCost: cfg.jev_rate != null ? (jt / 1000) * cfg.jev_rate : 0 };
|
|
343
|
+
};
|
|
344
|
+
const results = {};
|
|
345
|
+
for (const name of ROUTERS) {
|
|
346
|
+
let n = 0, under = 0, over = 0, exact = 0, cost = 0, base = 0, deepCost = 0, routerCost = 0; const ms = []; let missing = 0;
|
|
347
|
+
for (const row of rows) {
|
|
348
|
+
const p = pick(name, row);
|
|
349
|
+
if (!p) { missing++; continue; }
|
|
350
|
+
n++;
|
|
351
|
+
if (ix(p.tier) < ix(row.label)) under++; else if (ix(p.tier) > ix(row.label)) over++; else exact++;
|
|
352
|
+
cost += costOf(row, p.tier); base += costOf(row, 'balanced'); deepCost += costOf(row, 'deep'); routerCost += p.routerCost || 0;
|
|
353
|
+
if (p.ms != null) ms.push(p.ms);
|
|
354
|
+
}
|
|
355
|
+
const total = cost + routerCost;
|
|
356
|
+
results[name] = { n, missing, under, over, exact, under_rate: n ? under / n : null, over_rate: n ? over / n : null,
|
|
357
|
+
cost: Math.round(total * 1000) / 1000, router_cost: Math.round(routerCost * 1000) / 1000, router_cost_counted: name !== 'jev' || cfg.jev_rate != null,
|
|
358
|
+
vs_balanced: n && base ? Math.round((total / base - 1) * 1000) / 10 : null, vs_deep: n && deepCost ? Math.round((total / deepCost - 1) * 1000) / 10 : null,
|
|
359
|
+
router_ms_p50: pct(ms, 0.5), router_ms_p95: pct(ms, 0.95) };
|
|
360
|
+
}
|
|
361
|
+
const verdicts = {};
|
|
362
|
+
for (const name of ['keywords', 'jev']) {
|
|
363
|
+
const r = results[name]; const b = results['always-balanced'];
|
|
364
|
+
let verdict; let why;
|
|
365
|
+
if (r.n < cfg.min_labels) { verdict = 'unmeasured'; why = `${r.n} labelled prompt(s), fewer than ${cfg.min_labels}: suggestion only`; }
|
|
366
|
+
else if (r.under_rate > cfg.max_under_rate) { verdict = 'refused'; why = `under-routes ${(r.under_rate * 100).toFixed(1)}% of prompts, above the ${(cfg.max_under_rate * 100).toFixed(1)}% limit${r.cost < b.cost ? ' β it is cheaper, and that does not outweigh it' : ''}`; }
|
|
367
|
+
else if (!(r.cost < b.cost)) { verdict = 'not-cheaper'; why = 'it does not cost less than always-balanced, so routing buys nothing'; }
|
|
368
|
+
else { verdict = 'may-apply'; why = `under-routes ${(r.under_rate * 100).toFixed(1)}% (limit ${(cfg.max_under_rate * 100).toFixed(1)}%) and costs ${r.vs_balanced}% against always-balanced`; }
|
|
369
|
+
verdicts[name] = { verdict, why };
|
|
370
|
+
}
|
|
371
|
+
return { ok: true, labels: rows.length, unit, min_labels: cfg.min_labels, max_under_rate: cfg.max_under_rate, results, verdicts,
|
|
372
|
+
note: 'A may-apply verdict is evidence for the PM, not a switch: changing the policy stays the PM\'s edit (R10).' };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// What the router would do, written out for the PM. No prompt is involved.
|
|
376
|
+
function explain(root) {
|
|
377
|
+
const loaded = loadPolicy(root); if (!loaded.ok) return { ok: false, error: loaded.error };
|
|
378
|
+
const cfg = routerConfig(loaded.policy);
|
|
379
|
+
return { ok: true, source: loaded.source, version: loaded.policy.version || 1, router: cfg, router_in_file: !!loaded.policy.router,
|
|
380
|
+
tiers: Object.fromEntries(TIERS.map((t) => [t, loaded.policy.tiers[t]])), routes: (loaded.policy.routes || []).length,
|
|
381
|
+
stages: ['rules (explicit, schedule, skill, pinned, long prompt)', cfg.enabled ? 'jev classification (only in the workspace bridge, only when its gates hold)' : 'jev classification (off in this policy)', 'keyword routes'] };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/* The routed-prompt ledger lines. A WHITELIST: only these keys are written, so the text of a prompt or of an
|
|
385
|
+
* answer cannot reach the file by being passed in by mistake. The hash and length stand in for the prompt. */
|
|
386
|
+
const ROUTE_KEYS = ['source', 'stage', 'confidence', 'suggested_tier', 'applied_tier', 'overridden', 'shadow', 'jev_model_asked', 'jev_model_answered', 'tokens_in', 'tokens_out', 'router_ms', 'prompt_hash', 'prompt_len', 'skill'];
|
|
387
|
+
function logRoute(root, r, extra = {}) {
|
|
388
|
+
const e = { kind: 'route', id: extra.id || sha(r.prompt_hash + ':' + Date.now() + ':' + Math.random()).slice(0, 16), suggested_tier: r.tier, applied_tier: extra.applied_tier ?? null, overridden: false,
|
|
389
|
+
source: r.source, stage: r.stage, confidence: r.confidence, shadow: r.shadow, jev_model_asked: r.jev_model_asked ?? null, jev_model_answered: r.jev_model_answered ?? null,
|
|
390
|
+
tokens_in: r.tokens_in ?? null, tokens_out: r.tokens_out ?? null, router_ms: extra.router_ms ?? null, prompt_hash: r.prompt_hash, prompt_len: r.prompt_len, skill: extra.skill || null };
|
|
391
|
+
const clean = { kind: e.kind, id: e.id }; for (const k of ROUTE_KEYS) clean[k] = e[k];
|
|
392
|
+
return logDecision(root, clean) ? e.id : null;
|
|
393
|
+
}
|
|
394
|
+
const OUTCOMES = ['accepted', 'overridden', 'rerun_higher', 'corrected'];
|
|
395
|
+
function attachOutcome(root, id, o = {}) {
|
|
396
|
+
if (!id || !OUTCOMES.includes(o.outcome)) return false;
|
|
397
|
+
const e = { kind: 'route-outcome', ref: String(id).slice(0, 40), outcome: o.outcome };
|
|
398
|
+
if (o.applied_tier && TIERS.includes(o.applied_tier)) e.applied_tier = o.applied_tier;
|
|
399
|
+
return logDecision(root, e);
|
|
400
|
+
}
|
|
401
|
+
|
|
201
402
|
// ββ CLI ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
202
403
|
function main(argv) {
|
|
203
404
|
const a = argv.slice(2); const cmd = a[0]; const opt = {}; const rest = [];
|
|
204
|
-
for (let i = 1; i < a.length; i++) { if (a[i].startsWith('--')) { const k = a[i].slice(2); if (k === 'json') opt.json = true; else opt[k] = a[++i]; } else rest.push(a[i]); }
|
|
405
|
+
for (let i = 1; i < a.length; i++) { if (a[i].startsWith('--')) { const k = a[i].slice(2); if (k === 'json') opt.json = true; else if (k === 'explain') opt.explain = true; else opt[k] = a[++i]; } else rest.push(a[i]); }
|
|
205
406
|
const root = path.resolve(opt.root || process.cwd());
|
|
206
407
|
const out = (o, human) => { process.stdout.write(opt.json ? JSON.stringify(o, null, 2) + '\n' : human + '\n'); };
|
|
207
408
|
if (!cmd || cmd === 'help' || cmd === '--help') { process.stdout.write('usage: pmos-model.js show | init | recommend "the work" [--skill x] | resolve [--skill x] [--tier t] | buckets [--root <repo>] [--json]\n'); return cmd ? 0 : 1; }
|
|
@@ -225,15 +426,45 @@ function main(argv) {
|
|
|
225
426
|
out({ ...r, launch }, `${r.tier} β ${r.model} (effort ${r.effort})${r.pinned ? ' Β· pinned' : ''}\n why: ${r.why}${launch.state === 'no-model-control' ? `\n note: '${opt.agent}' has no verified model control in PMOS` : ''}`);
|
|
226
427
|
return 0;
|
|
227
428
|
}
|
|
429
|
+
if (cmd === 'route' && opt.explain) {
|
|
430
|
+
const x = explain(root); if (!x.ok) { process.stderr.write(x.error + '\n'); return 2; }
|
|
431
|
+
out(x, `policy: ${x.source} (version ${x.version}${x.router_in_file ? '' : ', no router section β defaults'})\n stages: ${x.stages.join(' β ')}\n router: ${JSON.stringify(x.router)}\n ${x.routes} keyword route(s); first match wins`);
|
|
432
|
+
return 0;
|
|
433
|
+
}
|
|
228
434
|
if (cmd === 'resolve') {
|
|
229
435
|
const r = resolve(root, { skill: opt.skill, text: opt.text, agent: opt.agent || 'claude', explicit: opt.tier ? { tier: opt.tier } : (opt.model ? { model: opt.model } : {}) });
|
|
230
436
|
if (!r.ok) { process.stderr.write(r.error + '\n'); return 2; }
|
|
231
437
|
out(r, r.tier ? `${r.tier} β ${r.model} (effort ${r.effort}) Β· decided by ${r.decidedBy} Β· ${r.state}` : 'not asked β no rule matched, so no flags (the harness default)');
|
|
232
438
|
return 0;
|
|
233
439
|
}
|
|
440
|
+
if (cmd === 'route') {
|
|
441
|
+
const prompt = rest.join(' ') || opt.text;
|
|
442
|
+
const t0 = Date.now();
|
|
443
|
+
const r = route(root, prompt, { skill: opt.skill, source: opt.source || 'agent', running: opt.running === 'true' });
|
|
444
|
+
if (!r.ok) { process.stderr.write(r.error + '\n'); return 2; }
|
|
445
|
+
const note = 'the Jev stage did not run: it lives in the workspace bridge, which holds the key';
|
|
446
|
+
const id = logRoute(root, r, { router_ms: Date.now() - t0, skill: opt.skill });
|
|
447
|
+
if (!id) process.stderr.write('(no decisions ledger kept: run `pmos-model init` to opt in)\n');
|
|
448
|
+
if (r.shadow) { out({ ok: true, shadow: true, id }, 'shadow mode: the suggestion was logged and is not shown'); return 0; }
|
|
449
|
+
out({ ...r, id, note }, `${r.tier} β ${r.model} (effort ${r.effort}) Β· stage ${r.stage} Β· ${r.confidence}${r.pinned ? ' Β· pinned' : ''}\n why: ${r.why}\n ${r.applies}\n note: ${note}`);
|
|
450
|
+
return 0;
|
|
451
|
+
}
|
|
452
|
+
if (cmd === 'route-outcome') {
|
|
453
|
+
const okk = attachOutcome(root, rest[0], { outcome: opt.outcome, applied_tier: opt.tier });
|
|
454
|
+
if (!okk) { process.stderr.write(`usage: route-outcome <id> --outcome ${OUTCOMES.join('|')} [--tier fast|balanced|deep] (needs the decisions ledger)\n`); return 2; }
|
|
455
|
+
out({ ok: true }, 'recorded'); return 0;
|
|
456
|
+
}
|
|
457
|
+
if (cmd === 'bakeoff') {
|
|
458
|
+
if (!opt.labels) { process.stderr.write('usage: bakeoff --labels <file.jsonl> β one { "prompt", "label" } per line; keep the file where you choose, outside the repository\n'); return 2; }
|
|
459
|
+
const l = readLabels(path.resolve(opt.labels)); if (!l.ok) { process.stderr.write(l.error + '\n'); return 2; }
|
|
460
|
+
const b = bakeoff(root, l.rows); if (!b.ok) { process.stderr.write(b.error + '\n'); return 2; }
|
|
461
|
+
const row = (k) => { const r = b.results[k]; return ` ${k.padEnd(16)} n ${String(r.n).padStart(4)} under ${r.under_rate == null ? ' β ' : (r.under_rate * 100).toFixed(1).padStart(5) + '%'} over ${r.over_rate == null ? ' β ' : (r.over_rate * 100).toFixed(1).padStart(5) + '%'} cost ${String(r.cost).padStart(9)} vs balanced ${r.vs_balanced == null ? 'β' : r.vs_balanced + '%'} vs deep ${r.vs_deep == null ? 'β' : r.vs_deep + '%'}${r.router_ms_p95 != null ? ` router p50 ${r.router_ms_p50} ms p95 ${r.router_ms_p95} ms` : ''}${r.router_cost_counted ? '' : ' (router cost not counted: no jev_rate)'}`; };
|
|
462
|
+
out(b, `${b.labels} labelled prompt(s); cost in ${b.unit}\n${ROUTERS.map(row).join('\n')}\n keywords: ${b.verdicts.keywords.verdict} β ${b.verdicts.keywords.why}\n jev: ${b.verdicts.jev.verdict} β ${b.verdicts.jev.why}\n ${b.note}`);
|
|
463
|
+
return 0;
|
|
464
|
+
}
|
|
234
465
|
if (cmd === 'buckets') { const b = evaluatorBuckets(root); out(b, Object.keys(b).length ? Object.entries(b).sort((x, y) => y[1] - x[1]).map(([m, n]) => ` ${m} ${n} verdict(s)`).join('\n') : 'no evaluator verdicts recorded'); return 0; }
|
|
235
466
|
process.stderr.write(`unknown command '${cmd}'\n`); return 2;
|
|
236
467
|
}
|
|
237
468
|
|
|
238
|
-
module.exports = { DEFAULT_POLICY, TIERS, EFFORTS, MODEL_RE, validate, loadPolicy, routeFor, resolve, recommend, launchFor, logDecision, evaluatorBuckets, evaluatorNote, modelsDir, stateDir, LAUNCH };
|
|
469
|
+
module.exports = { bakeoff, readLabels, ROUTERS, ROUTER_DEFAULTS, validateRouter, routerConfig, route, explain, logRoute, attachOutcome, OUTCOMES, ROUTE_KEYS, DEFAULT_POLICY, TIERS, EFFORTS, MODEL_RE, validate, loadPolicy, routeFor, resolve, recommend, launchFor, logDecision, evaluatorBuckets, evaluatorNote, modelsDir, stateDir, LAUNCH };
|
|
239
470
|
if (require.main === module) process.exit(main(process.argv));
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* PMOS status facts β three things the ladder cannot see, computed by code from git and the records.
|
|
4
|
+
*
|
|
5
|
+
* since_approval spec files (PRD, rubric, brief) with a commit AFTER the latest contract approval
|
|
6
|
+
* or the latest acceptance first appeared in git
|
|
7
|
+
* coverage requirement ids in the PRD that the rubric never cites, and ids the rubric cites
|
|
8
|
+
* that the PRD does not have
|
|
9
|
+
* record_says the record's free-text `status:` names a stage the derived ladder contradicts
|
|
10
|
+
*
|
|
11
|
+
* WHAT THESE ARE NOT. They are not step states and they are not gates. None of them can mark a step
|
|
12
|
+
* `done`, change the stage, change a colour or enter a problem count. They sit beside the ladder as
|
|
13
|
+
* flags with their basis (a commit, a file, a line), so the PM can look. A false `done` is worse than
|
|
14
|
+
* a false `pending`; a flag can only send someone to look.
|
|
15
|
+
*
|
|
16
|
+
* WHAT THEY NEVER WRITE. This module reads git and files. It appends to no ledger, edits no record
|
|
17
|
+
* and edits no document. Contract approval, acceptance, stop/hold/cancel, outcome verdicts, anchors
|
|
18
|
+
* and reviewer identity stay the PM's (D12, D70, D57).
|
|
19
|
+
*
|
|
20
|
+
* HONESTY. A check that cannot run reports `unknown` with the reason (a shallow clone, a row that is
|
|
21
|
+
* not in committed history, a rubric that cites no requirement id). It never reports a pass.
|
|
22
|
+
* Order comes from POSITION IN THE COMMIT LIST, never from clock time, as workflow-state.js does.
|
|
23
|
+
*
|
|
24
|
+
* Zero dependencies, stock Node. It runs in CI, in a terminal and inside the workspace server.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
const { execFileSync } = require('child_process');
|
|
30
|
+
|
|
31
|
+
// Same line shape the claims ledger uses for a PRD requirement (workspace/lib/claims.js REQ_RE), minus
|
|
32
|
+
// the prefixes PMOS PRDs use for something else: Q for an open question and S for a story.
|
|
33
|
+
const REQ_LINE = /^\s*[-*]\s+\*\*([A-PR-RT-Z]{1,3}\d{1,3})\b[^*]*\*\*/;
|
|
34
|
+
const CITE_ID = /\b([A-PR-RT-Z]{1,3}\d{1,3})\b/g;
|
|
35
|
+
const CAP = 20;
|
|
36
|
+
|
|
37
|
+
function git(root, args) {
|
|
38
|
+
try {
|
|
39
|
+
return execFileSync('git', ['-C', root, ...args], {
|
|
40
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 128 * 1024 * 1024,
|
|
41
|
+
});
|
|
42
|
+
} catch { return null; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ββ history: one pass over the commits and one over the ledgers, cached per root + head βββββββββ
|
|
46
|
+
const _hist = new Map(); // root -> { head, value }
|
|
47
|
+
|
|
48
|
+
function ledgerRels(root, L) {
|
|
49
|
+
const rel = (f) => path.relative(root, path.join(L.runsDir, f)).split(path.sep).join('/');
|
|
50
|
+
return { runs: rel('agent-runs.jsonl'), acceptances: rel('acceptances.jsonl') };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function history(root, L) {
|
|
54
|
+
const head = (git(root, ['rev-parse', '--verify', '--quiet', 'HEAD']) || '').trim();
|
|
55
|
+
const hit = _hist.get(root);
|
|
56
|
+
if (hit && hit.head === head && head) return hit.value;
|
|
57
|
+
let value;
|
|
58
|
+
if (!head) value = { ok: false, reason: 'no commits yet' };
|
|
59
|
+
else if ((git(root, ['rev-parse', '--is-shallow-repository']) || '').trim() === 'true') {
|
|
60
|
+
value = { ok: false, reason: 'shallow clone β no history to order commits by (CI needs fetch-depth: 0)' };
|
|
61
|
+
} else {
|
|
62
|
+
const log = git(root, ['log', '--reverse', '--format=%x1e%H %ct%x1f%s%x1f', '--name-only', 'HEAD']);
|
|
63
|
+
if (log == null) value = { ok: false, reason: 'git history unreadable' };
|
|
64
|
+
else {
|
|
65
|
+
const shas = []; const dates = []; const subjects = []; const changed = [];
|
|
66
|
+
for (const chunk of log.split('\x1e')) {
|
|
67
|
+
if (!chunk.trim()) continue;
|
|
68
|
+
const parts = chunk.split('\x1f');
|
|
69
|
+
const [sha, ct] = (parts[0] || '').trim().split(/\s+/);
|
|
70
|
+
if (!sha) continue;
|
|
71
|
+
shas.push(sha);
|
|
72
|
+
dates.push(Number.isFinite(parseInt(ct, 10)) ? parseInt(ct, 10) : null);
|
|
73
|
+
subjects.push(String(parts[1] || '').replace(/\s+/g, ' ').trim());
|
|
74
|
+
changed.push(new Set(String(parts[2] || '').split('\n').map((s) => s.trim()).filter(Boolean)));
|
|
75
|
+
}
|
|
76
|
+
const index = new Map(); shas.forEach((s, i) => index.set(s, i));
|
|
77
|
+
// First commit that ADDED a line carrying the approval itself β not a line that merely names
|
|
78
|
+
// the run. A contract is approved by `"PM_approved": true`, an acceptance by `"accepted": true`.
|
|
79
|
+
const rels = ledgerRels(root, L);
|
|
80
|
+
const approvals = new Map(); // `${kind}\0${run_id}` -> sha
|
|
81
|
+
const plog = git(root, ['log', '--reverse', '--format=%x1e%H', '-p', '-U0', '--no-color', 'HEAD', '--', rels.runs, rels.acceptances]);
|
|
82
|
+
if (plog != null) {
|
|
83
|
+
for (const chunk of plog.split('\x1e')) {
|
|
84
|
+
if (!chunk.trim()) continue;
|
|
85
|
+
const lines = chunk.split('\n');
|
|
86
|
+
const sha = (lines.shift() || '').trim();
|
|
87
|
+
let file = null;
|
|
88
|
+
for (const ln of lines) {
|
|
89
|
+
if (ln.startsWith('+++ ')) { file = ln.slice(6).trim(); continue; }
|
|
90
|
+
if (!ln.startsWith('+') || ln.startsWith('+++')) continue;
|
|
91
|
+
const m = /"run_id"\s*:\s*"([^"]+)"/.exec(ln);
|
|
92
|
+
if (!m) continue;
|
|
93
|
+
let kind = null;
|
|
94
|
+
if (file === rels.runs && /"PM_approved"\s*:\s*true/.test(ln)) kind = 'contract';
|
|
95
|
+
else if (file === rels.acceptances && /"accepted"\s*:\s*true/.test(ln)) kind = 'acceptance';
|
|
96
|
+
if (!kind) continue;
|
|
97
|
+
const key = `${kind}\0${m[1]}`;
|
|
98
|
+
if (!approvals.has(key)) approvals.set(key, sha);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
value = { ok: true, shas, dates, subjects, changed, index, approvals };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
_hist.set(root, { head, value });
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const norm = (p) => String(p || '').trim().replace(/^\.\//, '').replace(/^\/+/, '');
|
|
110
|
+
|
|
111
|
+
// ββ since_approval ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
112
|
+
function sinceApproval({ root, L, fm, runs, accepted }) {
|
|
113
|
+
const files = ['prd_path', 'rubric_path', 'brief_path'].map((k) => norm(fm[k])).filter((p) => p && !/^[<(β¦]/.test(p));
|
|
114
|
+
if (!files.length) return null;
|
|
115
|
+
const contractRun = [...(runs || [])].reverse().find((r) => r && r.pre_run_contract && r.pre_run_contract.PM_approved === true);
|
|
116
|
+
const acceptRow = [...(accepted || [])].reverse().find((a) => a && a.accepted === true);
|
|
117
|
+
if (!contractRun && !acceptRow) return null;
|
|
118
|
+
|
|
119
|
+
const h = history(root, L);
|
|
120
|
+
const out = { status: 'ok', approvals: [] };
|
|
121
|
+
const todo = [];
|
|
122
|
+
if (contractRun) todo.push({ kind: 'contract', run_id: contractRun.run_id });
|
|
123
|
+
if (acceptRow) todo.push({ kind: 'acceptance', run_id: acceptRow.run_id });
|
|
124
|
+
if (!h.ok) return { status: 'unknown', reason: h.reason, approvals: todo.map((t) => ({ ...t, status: 'unknown' })) };
|
|
125
|
+
|
|
126
|
+
for (const t of todo) {
|
|
127
|
+
const sha = t.run_id ? h.approvals.get(`${t.kind}\0${t.run_id}`) : undefined;
|
|
128
|
+
if (!sha) {
|
|
129
|
+
out.approvals.push({ ...t, status: 'unknown', reason: 'the approval row is not in committed history (or has no run_id)' });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const at = h.index.get(sha);
|
|
133
|
+
const changed = [];
|
|
134
|
+
for (let i = at + 1; i < h.shas.length; i++) {
|
|
135
|
+
for (const f of files) {
|
|
136
|
+
if (!h.changed[i].has(f)) continue;
|
|
137
|
+
changed.push({ file: f, sha: h.shas[i].slice(0, 12), date: h.dates[i] != null ? new Date(h.dates[i] * 1000).toISOString().slice(0, 10) : null, subject: h.subjects[i].slice(0, 90) });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
out.approvals.push({
|
|
141
|
+
...t, status: 'ok', approved_sha: sha.slice(0, 12), changed: changed.slice(0, CAP), changed_total: changed.length,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (out.approvals.some((a) => a.status === 'unknown')) out.status = out.approvals.every((a) => a.status === 'unknown') ? 'unknown' : 'partial';
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ββ coverage ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
149
|
+
function readText(root, rel) { try { return fs.readFileSync(path.join(root, rel), 'utf8'); } catch { return null; } }
|
|
150
|
+
|
|
151
|
+
function coverage({ root, fm }) {
|
|
152
|
+
const prd = norm(fm.prd_path); const rub = norm(fm.rubric_path);
|
|
153
|
+
if (!prd || !rub) return null;
|
|
154
|
+
const pt = readText(root, prd); const rt = readText(root, rub);
|
|
155
|
+
if (pt == null || rt == null) return { status: 'unknown', reason: 'the PRD or the rubric could not be read' };
|
|
156
|
+
const reqs = new Set();
|
|
157
|
+
for (const line of pt.split('\n')) { const m = REQ_LINE.exec(line); if (m) reqs.add(m[1]); }
|
|
158
|
+
if (!reqs.size) return { status: 'unknown', reason: 'the PRD lists no requirement ids' };
|
|
159
|
+
const cited = new Set();
|
|
160
|
+
for (const m of rt.matchAll(CITE_ID)) cited.add(m[1]);
|
|
161
|
+
// Only an id shaped like a requirement counts as a citation; a rubric that cites none says
|
|
162
|
+
// nothing about coverage either way, and is reported as undecidable rather than as all-missing.
|
|
163
|
+
const citedReqLike = [...cited].filter((id) => reqs.has(id));
|
|
164
|
+
if (!citedReqLike.length) return { status: 'unknown', reason: 'the rubric cites no requirement id of this PRD, so coverage is undecidable by id' };
|
|
165
|
+
const uncited = [...reqs].filter((id) => !cited.has(id));
|
|
166
|
+
const orphan = [...cited].filter((id) => /^R\d{1,3}$/.test(id) && !reqs.has(id));
|
|
167
|
+
return { status: 'ok', requirements: reqs.size, uncited, orphan_citations: orphan, basis: 'cited anywhere in the rubric, which is not the same as graded' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ββ record_says βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
171
|
+
const RANK = { intake: 0, spec: 1, contract: 2, build: 3, eval: 4, accepted: 5 };
|
|
172
|
+
const STAGE_WORD = /^\s*(intake|spec|contract|build|eval|accepted)\b/i;
|
|
173
|
+
|
|
174
|
+
function recordSays({ fm, lane, stage }) {
|
|
175
|
+
if (lane === 'chore') return null; // for a chore, status: IS the intent
|
|
176
|
+
const prose = String(fm.status || '').trim();
|
|
177
|
+
const m = STAGE_WORD.exec(prose);
|
|
178
|
+
if (!m) return null; // no stage word: nothing to compare
|
|
179
|
+
const claimed = m[1].toLowerCase();
|
|
180
|
+
const d = RANK[stage]; const c = RANK[claimed];
|
|
181
|
+
if (d == null || c == null) return null;
|
|
182
|
+
// Conservative on purpose. The prose is free text and the ladder's `eval` follows from any run
|
|
183
|
+
// being logged, so "build in progress" beside `eval` is not a contradiction. What is flagged:
|
|
184
|
+
// Β· the prose claims acceptance and no acceptance row exists (the lie that matters most),
|
|
185
|
+
// Β· the prose sits two or more stages BEHIND the ladder, or says anything but accepted on an
|
|
186
|
+
// accepted initiative.
|
|
187
|
+
let reason = null;
|
|
188
|
+
if (claimed === 'accepted' && stage !== 'accepted') reason = `says accepted, but there is no acceptance row (the ladder is at ${stage})`;
|
|
189
|
+
else if (stage === 'accepted' && claimed !== 'accepted') reason = `says ${claimed}, but the PM accepted this initiative`;
|
|
190
|
+
else if (d - c >= 2) reason = `says ${claimed}, but the ladder is at ${stage}`;
|
|
191
|
+
if (!reason) return null;
|
|
192
|
+
return { status: 'mismatch', claimed, derived: stage, reason, quote: prose.slice(0, 120) };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function compute(c) {
|
|
196
|
+
const { root, L, fm, lane, stage } = c;
|
|
197
|
+
let since = null; let cov = null; let says = null;
|
|
198
|
+
try { since = sinceApproval(c); } catch (e) { since = { status: 'unknown', reason: `since-approval check failed: ${e.message}` }; }
|
|
199
|
+
try { cov = lane === 'feature' ? coverage({ root, fm }) : null; } catch (e) { cov = { status: 'unknown', reason: `coverage check failed: ${e.message}` }; }
|
|
200
|
+
try { says = recordSays({ fm, lane, stage }); } catch { says = null; }
|
|
201
|
+
return { since_approval: since, coverage: cov, record_says: says };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = { compute, sinceApproval, coverage, recordSays, history };
|
|
@@ -54,6 +54,13 @@ const anchors = require('./anchor-resolve');
|
|
|
54
54
|
* server. It is shared now, for the same reason `anchor-resolve` is.
|
|
55
55
|
*/
|
|
56
56
|
const delivery = require('./delivery-index');
|
|
57
|
+
/*
|
|
58
|
+
* Three flags the ladder cannot see (status-from-artifacts, Run 1): a spec file changed after an
|
|
59
|
+
* approval, requirement ids the rubric never cites, and `status:` prose the ladder contradicts. They
|
|
60
|
+
* sit BESIDE the steps. They never change a step, the stage, a colour or a count, and they write
|
|
61
|
+
* nothing. See scripts/status-facts.js.
|
|
62
|
+
*/
|
|
63
|
+
const statusFacts = require('./status-facts');
|
|
57
64
|
|
|
58
65
|
// The OKR documents and the budget registry are read once per root, not once per initiative: the
|
|
59
66
|
// board derives ~100 ladders in a single pass and re-reading the same three files each time is the
|
|
@@ -1235,10 +1242,15 @@ function computeWorkflowDerived(id, opts) {
|
|
|
1235
1242
|
|| steps.find((s) => s.status === 'pending' && s.next)
|
|
1236
1243
|
|| null;
|
|
1237
1244
|
|
|
1245
|
+
// `opts.facts === false` exists so a test can prove the flags change nothing else in the ladder.
|
|
1246
|
+
const facts = (opts && opts.facts === false) ? {}
|
|
1247
|
+
: statusFacts.compute({ root, L, id, fm, lane: effLane, stage, runs, accepted });
|
|
1248
|
+
|
|
1238
1249
|
return {
|
|
1239
1250
|
ok: true, id, lane, laneKnown: knownLane, legacy, in_change: inChange,
|
|
1240
1251
|
last_activity: lastActivity, accepted: accepted.length > 0, stage, record: recRel,
|
|
1241
1252
|
title: fm.title || id, anchor: fm.parent_kr || null, profile: 'product-code',
|
|
1253
|
+
...facts,
|
|
1242
1254
|
steps, blockers: blockers.map((s) => s.key), gate_failures: gateFailures.map((s) => s.rule || s.key),
|
|
1243
1255
|
next: next ? { step: next.key, why: next.evidence, action: next.next || next.evidence } : null,
|
|
1244
1256
|
range: range.ok ? { base: range.base, commits: range.shas.length, whole_history: !!range.wholeHistory } : { error: range.reason },
|
|
@@ -1390,6 +1402,44 @@ function brief(s, n) {
|
|
|
1390
1402
|
return t.length > n ? `${t.slice(0, n - 1)}β¦` : t;
|
|
1391
1403
|
}
|
|
1392
1404
|
|
|
1405
|
+
/**
|
|
1406
|
+
* The three status-from-artifacts lines, one plain sentence each with its basis. A flag, never a
|
|
1407
|
+
* step: nothing here is a state, and a missing basis prints nothing (never a pass).
|
|
1408
|
+
*/
|
|
1409
|
+
function renderFacts(w) {
|
|
1410
|
+
const out = [];
|
|
1411
|
+
const sa = w.since_approval;
|
|
1412
|
+
if (sa) {
|
|
1413
|
+
if (sa.status === 'unknown') out.push(` ${c(90, '?')} since approval ${c(90, `cannot tell β ${sa.reason}`)}`);
|
|
1414
|
+
else {
|
|
1415
|
+
for (const a of sa.approvals) {
|
|
1416
|
+
const what = a.kind === 'contract' ? 'the contract was approved' : 'the PM accepted';
|
|
1417
|
+
if (a.status === 'unknown') out.push(` ${c(90, '?')} since approval ${c(90, `${what} (${a.run_id || 'no run id'}) β ${a.reason}`)}`);
|
|
1418
|
+
else if (!a.changed.length) out.push(` ${c(90, 'Β·')} since approval ${c(90, `no spec file changed since ${what} (${a.approved_sha})`)}`);
|
|
1419
|
+
else {
|
|
1420
|
+
const files = [...new Set(a.changed.map((x) => x.file))].join(', ');
|
|
1421
|
+
const last = a.changed[a.changed.length - 1];
|
|
1422
|
+
out.push(` ${c(33, '!')} since approval ${files} changed after ${what} (${a.approved_sha}); ${a.changed_total} commit(s), latest ${last.sha} ${last.date || ''}`);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
const cv = w.coverage;
|
|
1428
|
+
if (cv) {
|
|
1429
|
+
if (cv.status === 'unknown') out.push(` ${c(90, '?')} coverage ${c(90, `cannot tell β ${cv.reason}`)}`);
|
|
1430
|
+
else if (!cv.uncited.length && !cv.orphan_citations.length) out.push(` ${c(90, 'Β·')} coverage ${c(90, `all ${cv.requirements} requirement ids are cited in the rubric`)}`);
|
|
1431
|
+
else {
|
|
1432
|
+
const bits = [];
|
|
1433
|
+
if (cv.uncited.length) bits.push(`${cv.uncited.join(', ')} not cited in the rubric`);
|
|
1434
|
+
if (cv.orphan_citations.length) bits.push(`${cv.orphan_citations.join(', ')} cited but not in the PRD`);
|
|
1435
|
+
out.push(` ${c(33, '!')} coverage ${bits.join('; ')} (cited is not graded)`);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
const rs = w.record_says;
|
|
1439
|
+
if (rs) out.push(` ${c(33, '!')} record says status: ${rs.reason}`);
|
|
1440
|
+
return out;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1393
1443
|
function renderLadder(w) {
|
|
1394
1444
|
if (!w.ok) return c(31, `β ${w.error}`);
|
|
1395
1445
|
const out = [];
|
|
@@ -1403,6 +1453,8 @@ function renderLadder(w) {
|
|
|
1403
1453
|
out.push(` ${mark(s.status)} ${s.status === 'na' || s.status === 'pending' ? c(90, label) : label} ${brief(s.evidence, 110)}`);
|
|
1404
1454
|
if (s.next) out.push(` ${c(90, 'β')} ${c(s.status === 'blocked' ? 33 : 90, brief(s.next, 150))}`);
|
|
1405
1455
|
}
|
|
1456
|
+
const flags = renderFacts(w);
|
|
1457
|
+
if (flags.length) { out.push(` ${c(90, 'β'.repeat(78))}`); out.push(...flags); }
|
|
1406
1458
|
out.push('');
|
|
1407
1459
|
if (w.next) out.push(` ${c(1, 'next:')} ${w.next.action}`);
|
|
1408
1460
|
else out.push(` ${c(32, 'nothing blocking β the ladder is complete')}`);
|