create-pmos 0.47.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/package.json +1 -1
  2. package/payload/AGENTS-HUMAN.md +1 -1
  3. package/payload/AGENTS.md +1 -1
  4. package/payload/KIT-VERSION +2 -2
  5. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
  6. package/payload/mods/pmos-cockpit/README.md +14 -3
  7. package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +34 -6
  8. package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +22 -3
  9. package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +16 -3
  10. package/payload/mods/pmos-cockpit/hooks/lib/route.mjs +95 -0
  11. package/payload/mods/pmos-cockpit/hooks/register.tsx +104 -19
  12. package/payload/mods/pmos-cockpit/types/index.d.ts +1 -1
  13. package/payload/planning/evals/eval-calibration.md +1 -1
  14. package/payload/planning/okrs/health-budgets.md +1 -1
  15. package/payload/scripts/pmos-log.sh +18 -8
  16. package/payload/scripts/pmos-model.js +233 -2
  17. package/payload/scripts/status-facts.js +214 -0
  18. package/payload/scripts/workflow-state.js +59 -2
  19. package/payload/state/discoveries.md +1 -1
  20. package/payload/state/log.md +1 -1
  21. package/workspace/README.md +2 -2
  22. package/workspace/bin/live-notes.js +133 -0
  23. package/workspace/design-parity.js +328 -21
  24. package/workspace/doc-category-bench.js +165 -0
  25. package/workspace/fixtures/doc-category-labelled.json +167 -0
  26. package/workspace/guide/index.md +2 -1
  27. package/workspace/guide/initiatives-and-next-steps.md +17 -2
  28. package/workspace/guide/knowledge-graph.md +94 -1
  29. package/workspace/guide/layout.md +31 -2
  30. package/workspace/guide/live-notes.md +78 -0
  31. package/workspace/guide/proactive-engine.md +6 -2
  32. package/workspace/guide/validate-with-typesafe.md +31 -2
  33. package/workspace/lib/claims.js +51 -2
  34. package/workspace/lib/doc-category-corrections.js +113 -0
  35. package/workspace/lib/doc-category-writeback.js +110 -0
  36. package/workspace/lib/doc-category.js +225 -0
  37. package/workspace/lib/engine/pmos-model.js +233 -2
  38. package/workspace/lib/engine/status-facts.js +214 -0
  39. package/workspace/lib/engine/workflow-state.js +59 -2
  40. package/workspace/lib/jev-categorize.js +433 -0
  41. package/workspace/lib/jev-claims.js +59 -15
  42. package/workspace/lib/jev-live.js +37 -0
  43. package/workspace/lib/jev-route.js +139 -0
  44. package/workspace/lib/kg-cli.js +3 -1
  45. package/workspace/lib/kg.js +6 -1
  46. package/workspace/lib/learn.js +5 -3
  47. package/workspace/lib/live-notes.js +1035 -0
  48. package/workspace/lib/typesafe-settings.js +10 -2
  49. package/workspace/package-lock.json +1 -1
  50. package/workspace/package.json +5 -4
  51. package/workspace/public/pm/app.js +148 -79
  52. package/workspace/public/pm/data.js +78 -0
  53. package/workspace/public/pm/fixture.js +40 -2
  54. package/workspace/public/pm/modes-live.js +279 -18
  55. package/workspace/public/pm/panel-cockpit.js +638 -0
  56. package/workspace/public/pm/panel-detail.js +212 -8
  57. package/workspace/public/pm/panel-initiatives.js +9 -9
  58. package/workspace/public/pm/panel-newsess.js +145 -86
  59. package/workspace/public/pm/panel-repos.js +34 -17
  60. package/workspace/public/pm/panel-tabs.js +249 -29
  61. package/workspace/public/pm/state.js +48 -4
  62. package/workspace/public/pm/tab-doc.js +244 -16
  63. package/workspace/public/pm/tab-term.js +11 -1
  64. package/workspace/public/pm/tokens.css +4 -3
  65. package/workspace/public/pm/view-addrepo.js +207 -0
  66. package/workspace/public/pm/view-files.js +197 -0
  67. package/workspace/public/pm/view-guide.js +141 -0
  68. package/workspace/public/pm/view-knowledge.js +225 -0
  69. package/workspace/public/pm/view-livenotes.js +397 -0
  70. package/workspace/public/pm/view-memory.js +122 -0
  71. package/workspace/public/pm/view-portfolio.js +153 -0
  72. package/workspace/public/pm/view-reports.js +190 -0
  73. package/workspace/public/pm/view-scheduled.js +161 -0
  74. package/workspace/public/pm/view-skills.js +216 -0
  75. package/workspace/public/pm/view-today.js +168 -0
  76. package/workspace/public/pm/view-validate.js +171 -0
  77. package/workspace/server.js +349 -7
  78. package/workspace/skills/live-notes.skill +58 -0
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-04T10:38:12Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-05T00:43:02Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-04T10:38:12Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-05T00:43:02Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -14,13 +14,15 @@
14
14
  # pmos-log.sh accept <run_id> <initiative_id> <pass|fail> <note>
15
15
  # pmos-log.sh outcome <initiative_id> <validated|refuted|inconclusive> [evidence]
16
16
  # pmos-log.sh friction <note...>
17
- # pmos-log.sh grant <merge:<PR or branch>|publish:<artifact>> <pm> <instruction> [ttl_minutes]
17
+ # pmos-log.sh grant <merge:<PR or branch>|publish:<artifact>> <pm> <instruction> [ttl_minutes] [initiative_id]
18
18
  # pmos-log.sh grant-use <grant_id> <action>
19
19
  #
20
20
  # `grant` and `grant-use` belong to the cockpit mod (D81). A grant is the PM's typed permission for
21
21
  # ONE action: single use, 120 minutes unless a shorter time is given (24 hours at most), one target. A
22
22
  # standing scope (docs-sync, outward:*) and any other scope is refused. The mod mints a grant only from
23
- # the PM's typed /pmos-grant or a press, and denies a model that runs either subcommand.
23
+ # the PM's typed /pmos-grant or a press, and denies a model that runs either subcommand. A grant may
24
+ # name its initiative (a slug with a record in state/initiatives/), so the mod's local gate has an
25
+ # initiative to run against on a branch that names none (Run 7). Without it the row is unchanged.
24
26
  # PMOS_STATE_DIR names the folder of the ledgers when it is not <kit>/state (PMOS's own repository).
25
27
  set -euo pipefail
26
28
 
@@ -167,11 +169,11 @@ case "$cmd" in
167
169
  echo "✅ appended to state/discoveries.md: - $today $* #friction"
168
170
  ;;
169
171
  grant)
170
- [ $# -ge 3 ] || fail "usage: grant <merge:<PR or branch>|publish:<artifact>> <pm> <instruction> [ttl_minutes]"
172
+ [ $# -ge 3 ] || fail "usage: grant <merge:<PR or branch>|publish:<artifact>> <pm> <instruction> [ttl_minutes] [initiative_id]"
171
173
  nonempty pm "$2"; nonempty instruction "$3"
172
- row="$(python3 - "$1" "$2" "$3" "${4:-120}" <<'PY'
173
- import datetime, json, re, secrets, sys
174
- scope, pm, instruction, ttl = sys.argv[1:5]
174
+ row="$(python3 - "$1" "$2" "$3" "${4:-120}" "${5:-}" "$STATE" <<'PY'
175
+ import datetime, json, os, re, secrets, sys
176
+ scope, pm, instruction, ttl, initiative, state = sys.argv[1:7]
175
177
  def die(m):
176
178
  sys.stderr.write("pmos-log: ERROR — %s\n" % m); sys.exit(1)
177
179
  if re.match(r"^(docs-sync|outward)(:|$)", scope):
@@ -184,10 +186,18 @@ if not re.match(pat, target) or "*" in target or ".." in target:
184
186
  die('"%s" is not a single %s. A grant names one target; a wildcard is refused.' % (target, "PR number or branch name" if kind == "merge" else "artifact name"))
185
187
  if not re.match(r"^[0-9]{1,4}$", ttl) or not (1 <= int(ttl) <= 1440):
186
188
  die("ttl_minutes must be a whole number from 1 to 1440, got '%s'." % ttl)
189
+ if initiative:
190
+ if not re.match(r"^[a-z0-9][a-z0-9._-]{0,99}$", initiative):
191
+ die('initiative "%s" is not a plain lowercase slug.' % initiative)
192
+ if not os.path.isfile(os.path.join(state, "initiatives", initiative + ".md")):
193
+ die('initiative "%s" has no record at state/initiatives/%s.md.' % (initiative, initiative))
187
194
  now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
188
195
  fmt = lambda d: d.strftime("%Y-%m-%dT%H:%M:%SZ")
189
- print(json.dumps({"type": "grant", "id": "g-%d-%s" % (int(now.timestamp()), secrets.token_hex(3)), "scope": kind, "target": target,
190
- "pm": pm, "at": fmt(now), "expires_at": fmt(now + datetime.timedelta(minutes=int(ttl))), "uses": 1, "instruction": instruction}, separators=(",", ":")))
196
+ row = {"type": "grant", "id": "g-%d-%s" % (int(now.timestamp()), secrets.token_hex(3)), "scope": kind, "target": target,
197
+ "pm": pm, "at": fmt(now), "expires_at": fmt(now + datetime.timedelta(minutes=int(ttl))), "uses": 1, "instruction": instruction}
198
+ if initiative:
199
+ row["initiative"] = initiative
200
+ print(json.dumps(row, separators=(",", ":")))
191
201
  PY
192
202
  )" || exit 1
193
203
  append grants.jsonl "$row"
@@ -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,214 @@
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
+ const SPEC_KEYS = ['prd_path', 'rubric_path', 'brief_path'];
113
+ function sinceApproval({ root, L, fm, runs, accepted, keys = SPEC_KEYS }) {
114
+ const files = [...new Set(keys.map((k) => norm(fm[k])).filter((p) => p && !/^[<(…]/.test(p)))];
115
+ if (!files.length) return null;
116
+ const contractRun = [...(runs || [])].reverse().find((r) => r && r.pre_run_contract && r.pre_run_contract.PM_approved === true);
117
+ const acceptRow = [...(accepted || [])].reverse().find((a) => a && a.accepted === true);
118
+ if (!contractRun && !acceptRow) return null;
119
+
120
+ const h = history(root, L);
121
+ const out = { status: 'ok', approvals: [] };
122
+ const todo = [];
123
+ if (contractRun) todo.push({ kind: 'contract', run_id: contractRun.run_id });
124
+ if (acceptRow) todo.push({ kind: 'acceptance', run_id: acceptRow.run_id });
125
+ if (!h.ok) return { status: 'unknown', reason: h.reason, approvals: todo.map((t) => ({ ...t, status: 'unknown' })) };
126
+
127
+ for (const t of todo) {
128
+ const sha = t.run_id ? h.approvals.get(`${t.kind}\0${t.run_id}`) : undefined;
129
+ if (!sha) {
130
+ out.approvals.push({ ...t, status: 'unknown', reason: 'the approval row is not in committed history (or has no run_id)' });
131
+ continue;
132
+ }
133
+ const at = h.index.get(sha);
134
+ const changed = [];
135
+ for (let i = at + 1; i < h.shas.length; i++) {
136
+ for (const f of files) {
137
+ if (!h.changed[i].has(f)) continue;
138
+ 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) });
139
+ }
140
+ }
141
+ out.approvals.push({
142
+ ...t, status: 'ok', approved_sha: sha.slice(0, 12), changed: changed.slice(0, CAP), changed_total: changed.length,
143
+ });
144
+ }
145
+ if (out.approvals.some((a) => a.status === 'unknown')) out.status = out.approvals.every((a) => a.status === 'unknown') ? 'unknown' : 'partial';
146
+ return out;
147
+ }
148
+
149
+ // ── coverage ────────────────────────────────────────────────────────────────────────────────────
150
+ function readText(root, rel) { try { return fs.readFileSync(path.join(root, rel), 'utf8'); } catch { return null; } }
151
+
152
+ function coverage({ root, fm }) {
153
+ const prd = norm(fm.prd_path); const rub = norm(fm.rubric_path);
154
+ if (!prd || !rub) return null;
155
+ const pt = readText(root, prd); const rt = readText(root, rub);
156
+ if (pt == null || rt == null) return { status: 'unknown', reason: 'the PRD or the rubric could not be read' };
157
+ const reqs = new Set();
158
+ for (const line of pt.split('\n')) { const m = REQ_LINE.exec(line); if (m) reqs.add(m[1]); }
159
+ if (!reqs.size) return { status: 'unknown', reason: 'the PRD lists no requirement ids' };
160
+ const cited = new Set();
161
+ for (const m of rt.matchAll(CITE_ID)) cited.add(m[1]);
162
+ // Only an id shaped like a requirement counts as a citation; a rubric that cites none says
163
+ // nothing about coverage either way, and is reported as undecidable rather than as all-missing.
164
+ const citedReqLike = [...cited].filter((id) => reqs.has(id));
165
+ if (!citedReqLike.length) return { status: 'unknown', reason: 'the rubric cites no requirement id of this PRD, so coverage is undecidable by id' };
166
+ const uncited = [...reqs].filter((id) => !cited.has(id));
167
+ const orphan = [...cited].filter((id) => /^R\d{1,3}$/.test(id) && !reqs.has(id));
168
+ return { status: 'ok', requirements: reqs.size, uncited, orphan_citations: orphan, basis: 'cited anywhere in the rubric, which is not the same as graded' };
169
+ }
170
+
171
+ // ── record_says ─────────────────────────────────────────────────────────────────────────────────
172
+ const RANK = { intake: 0, spec: 1, contract: 2, build: 3, eval: 4, accepted: 5 };
173
+ const STAGE_WORD = /^\s*(intake|spec|contract|build|eval|accepted)\b/i;
174
+
175
+ function recordSays({ fm, lane, stage }) {
176
+ if (lane === 'chore') return null; // for a chore, status: IS the intent
177
+ const prose = String(fm.status || '').trim();
178
+ const m = STAGE_WORD.exec(prose);
179
+ if (!m) return null; // no stage word: nothing to compare
180
+ const claimed = m[1].toLowerCase();
181
+ const d = RANK[stage]; const c = RANK[claimed];
182
+ if (d == null || c == null) return null;
183
+ // Conservative on purpose. The prose is free text and the ladder's `eval` follows from any run
184
+ // being logged, so "build in progress" beside `eval` is not a contradiction. What is flagged:
185
+ // · the prose claims acceptance and no acceptance row exists (the lie that matters most),
186
+ // · the prose sits two or more stages BEHIND the ladder, or says anything but accepted on an
187
+ // accepted initiative.
188
+ let reason = null;
189
+ if (claimed === 'accepted' && stage !== 'accepted') reason = `says accepted, but there is no acceptance row (the ladder is at ${stage})`;
190
+ else if (stage === 'accepted' && claimed !== 'accepted') reason = `says ${claimed}, but the PM accepted this initiative`;
191
+ else if (d - c >= 2) reason = `says ${claimed}, but the ladder is at ${stage}`;
192
+ if (!reason) return null;
193
+ return { status: 'mismatch', claimed, derived: stage, reason, quote: prose.slice(0, 120) };
194
+ }
195
+
196
+ function compute(c) {
197
+ const { root, L, fm, lane, stage } = c;
198
+ let since = null; let cov = null; let says = null;
199
+ try { since = sinceApproval(c); } catch (e) { since = { status: 'unknown', reason: `since-approval check failed: ${e.message}` }; }
200
+ try { cov = lane === 'feature' ? coverage({ root, fm }) : null; } catch (e) { cov = { status: 'unknown', reason: `coverage check failed: ${e.message}` }; }
201
+ try { says = recordSays({ fm, lane, stage }); } catch { says = null; }
202
+ return { since_approval: since, coverage: cov, record_says: says };
203
+ }
204
+
205
+ // The pm-artifacts profile (D68): the brief and the deliverable after the PM's acceptance. A document repository has
206
+ // no contract runs and no requirement ids, and its stages are not the product-code stage words, so only this one applies.
207
+ function computeArtifacts({ root, L, fm, accepted }) {
208
+ let since = null;
209
+ try { since = sinceApproval({ root, L, fm, runs: [], accepted, keys: ['brief_path', 'prd_path', 'doc_path'] }); }
210
+ catch (e) { since = { status: 'unknown', reason: `since-approval check failed: ${e.message}` }; }
211
+ return { since_approval: since, coverage: null, record_says: null };
212
+ }
213
+
214
+ module.exports = { compute, computeArtifacts, sinceApproval, coverage, recordSays, history };