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')}`);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "SKILL-FORMAT.md — Required Format for PMOS Skills"
|
|
4
|
+
description: "The required structure of every .skill file in PMOS, and why it keeps skills cheap to keep available and expensive to load only when used."
|
|
4
5
|
timestamp: 2026-07-23
|
|
5
6
|
---
|
|
6
7
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-04T21:21:45Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "PMOS Terminal Workspace"
|
|
4
|
-
timestamp: 2026-
|
|
4
|
+
timestamp: 2026-10-04
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# PMOS Terminal Workspace
|
|
@@ -76,7 +76,7 @@ After the session ends, `GET /api/next-step/summary` returns the files the branc
|
|
|
76
76
|
|
|
77
77
|
## Today: what is waiting for you (`proactive-engine`, slice D1)
|
|
78
78
|
|
|
79
|
-
`lib/proactive.js` is a pure core: it reads the records the workspace already derives and finds five conditions: a need waiting on the PM, an initiative returned at acceptance, an ended next-step session with no review, a scheduled task whose last run failed, and an active initiative with no agent run for the stalled threshold (5 days by default). The ranking is fixed and stated in the guide. `GET /api/proactive/today` computes the list on each request. A scan (`POST /api/proactive/scan`, and a 10-minute automatic tick inside the existing 60-second interval) appends new items once a day to `signals.jsonl`. Feedback (`useful`, `not-useful`, `wrong-priority`, `missed` with a note, and the events `opened`, `dismissed`) is appended to `proactive-feedback.jsonl`; a dismissed item is hidden for 7 days. Settings are in `proactive-settings.json` (engine on or off, one trigger off, the stalled threshold, quiet hours, pause learning
|
|
79
|
+
`lib/proactive.js` is a pure core: it reads the records the workspace already derives and finds five conditions: a need waiting on the PM, an initiative returned at acceptance, an ended next-step session with no review, a scheduled task whose last run failed, and an active initiative with no agent run for the stalled threshold (5 days by default). The ranking is fixed and stated in the guide. `GET /api/proactive/today` computes the list on each request. A scan (`POST /api/proactive/scan`, and a 10-minute automatic tick inside the existing 60-second interval) appends new items once a day to `signals.jsonl`. Feedback (`useful`, `not-useful`, `wrong-priority`, `missed` with a note, and the events `opened`, `dismissed`) is appended to `proactive-feedback.jsonl`; a dismissed item is hidden for 7 days. Settings are in `proactive-settings.json` (engine on or off, one trigger off, the stalled threshold, quiet hours, pause learning). The same actions are in `pmos proactive today|scan|settings|feedback` (`lib/proactive-cli.js`), a thin client of those routes. The list is drawn in the navigation column by the `today` drawer item. It runs only while the server runs, reads no mail or calendar, and starts no session, sends nothing and changes no record of an initiative (`proactive.test.js` E25-E26 read the source for that). Tests: `proactive.test.js` (pure core, routes, CLI) and `pm-r22-test.js` (the page). The guide page is `guide/proactive-engine.md`. **Learning (D4).** `lib/learn.js` is a second pure core. It joins the PM's verdicts, events and step reviews to items through the candidate id in `signals.jsonl` (which now records the initiative's lane), moves three bounded weights (per trigger, per lane, per trigger and time of day; caps 8, 4, 3 and 15 in all, no cell before 5 events) that `rank` adds to the fixed score, reports a 7-day rollup with every count and a watermelon check, leaves a trigger off Today when it was rated useful under 30% of the time over 10 or more ratings in 28 days, and proposes lessons from four templates. A lesson is approved, rejected or retired only by the PM's press; an approved lesson is read by the morning-brief session through `pmos proactive learn lessons` and goes into no other prompt, so the C1 prompt and plan hash are unchanged. The weekly review runs from the existing 10-minute proactive tick and does not catch up. needs and returned are never lowered or narrowed. Pause learning gives zero weights and no review. Ledgers: `learn-events.jsonl`, `lessons.jsonl`, `proactive-rollups.jsonl`. Routes `GET /api/proactive/learn` and `POST /api/proactive/learn/{run,lesson,restore,reset}`; commands `pmos proactive learn …`. Tests: `learn.test.js` and `pm-r26-test.js`.
|
|
80
80
|
|
|
81
81
|
## The morning brief (`proactive-engine`, slice D2)
|
|
82
82
|
|
|
@@ -140,7 +140,7 @@ The three-column layout is retired. The surface is now **four collapsible panels
|
|
|
140
140
|
- **Documents read like documents** — the identity block becomes a card, tables are tables, nested/ordered/task lists survive, and a relative `.md` link opens that document in the reader.
|
|
141
141
|
- **Preview more than markdown** — `.skill` files render as markdown; text/config files open as escaped source; `.html` opens as source with an opt-in **sandboxed** render (scripts + network denied); markdown renders **repo-relative images inline**. A remote `` image is never fetched (no zero-click beacon); a binary stays listed but inert (open it in your editor).
|
|
142
142
|
|
|
143
|
-
**Fixture mode.** `?fixture=proto`
|
|
143
|
+
**Fixture mode.** `?fixture=cockpit` and `?fixture=proto` render the whole surface from a bundled seed file, with the server unable to read any repository. `?fixture=cockpit` holds the seed of the cockpit mockup, and `npm run test:parity` compares it with the mockup board WsA (the blocking CI step). `?fixture=proto` holds the seed of the four-panel prototype that the cockpit replaced, and `npm run test:parity:four-panel` compares it with that prototype. Fixture mode is never the default.
|
|
144
144
|
|
|
145
145
|
## Artifact links are declarations, not integrations
|
|
146
146
|
The PMOS server makes no outbound request and holds no credential, with one named exception ([D79](/okf/products/pmos/adr/d79-typesafe-validation-egress.md)): when you save a TypeSafe key and turn validation on for a repository, the **validate** page sends that repository's document text to `api.typesafe.ai`, from one module, only after you confirm the plan. With no key, nothing is sent. The key stays on this machine, is never shown again, and is never given to a terminal. The consistency checks at the top of the **validate** page are local: they read files and git and send nothing. Automatic checking is off until you switch it on for a repository, and then the workspace appends problems to that repository's `claims.jsonl`. A second switch, **check with Jev automatically** ([D80](/okf/products/pmos/adr/d80-jev-claim-pairs.md)), is also off; only when a key, validation, automatic checking and that switch all hold does the workspace send pairs of short claim texts that changed, after a first pass that records what exists and sends nothing. The workspace makes no request you did not ask for. Its git network verbs are named and press-only: `Sync` runs `fetch`, a fast-forward or an own-commit rebase, a commit of record paths, and `push` — only when you press it, never on a timer or on load, never forced ([D70](/okf/products/pmos/adr/d70-stopped-initiatives-and-sync.md)). A web page opens in a tab only when you type or click its address, and a restored web tab loads only when you press "load this page" ([D69](/okf/products/pmos/adr/d69-web-tab.md)). Linking an artifact to an external destination therefore records a **declaration** — `{artifact_path, kind, target}` appended to `pmos/state/artifact-links.jsonl`. The sync panel renders as designed, and with no connector configured its summary line says exactly that: *no connector configured — nothing was read, and nothing will be written*, with the approval buttons disabled. A difference summary it never read would be the watermelon this repo has a word for.
|