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.
Files changed (83) 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 +1 -1
  5. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
  6. package/payload/mods/pmos-cockpit/README.md +13 -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/handover-run.mjs +33 -0
  11. package/payload/mods/pmos-cockpit/hooks/lib/handover.mjs +267 -0
  12. package/payload/mods/pmos-cockpit/hooks/register.tsx +189 -18
  13. package/payload/mods/pmos-cockpit/types/index.d.ts +16 -1
  14. package/payload/okf/core/concepts/frontmatter-format.md +6 -1
  15. package/payload/planning/evals/eval-calibration.md +1 -1
  16. package/payload/planning/okrs/health-budgets.md +1 -1
  17. package/payload/scripts/pmos-log.sh +18 -8
  18. package/payload/scripts/pmos-model.js +233 -2
  19. package/payload/scripts/status-facts.js +204 -0
  20. package/payload/scripts/workflow-state.js +52 -0
  21. package/payload/skills/SKILL-FORMAT.md +1 -0
  22. package/payload/state/discoveries.md +1 -1
  23. package/workspace/README.md +3 -3
  24. package/workspace/bin/live-notes.js +133 -0
  25. package/workspace/design-parity.js +328 -21
  26. package/workspace/doc-category-bench.js +165 -0
  27. package/workspace/fixtures/doc-category-labelled.json +167 -0
  28. package/workspace/guide/initiatives-and-next-steps.md +16 -2
  29. package/workspace/guide/knowledge-graph.md +89 -2
  30. package/workspace/guide/layout.md +30 -2
  31. package/workspace/guide/proactive-engine.md +26 -4
  32. package/workspace/guide/scheduled-tasks-and-cli.md +1 -0
  33. package/workspace/guide/statuses.md +1 -0
  34. package/workspace/guide/validate-with-typesafe.md +17 -1
  35. package/workspace/lib/claims.js +51 -2
  36. package/workspace/lib/doc-category-corrections.js +113 -0
  37. package/workspace/lib/doc-category-writeback.js +110 -0
  38. package/workspace/lib/doc-category.js +225 -0
  39. package/workspace/lib/engine/pmos-model.js +233 -2
  40. package/workspace/lib/engine/status-facts.js +204 -0
  41. package/workspace/lib/engine/workflow-state.js +52 -0
  42. package/workspace/lib/jev-categorize.js +399 -0
  43. package/workspace/lib/jev-claims.js +59 -15
  44. package/workspace/lib/jev-route.js +129 -0
  45. package/workspace/lib/kg-cli.js +5 -2
  46. package/workspace/lib/kg-dangling-baseline.json +1 -0
  47. package/workspace/lib/kg-dangling-rules.json +1 -0
  48. package/workspace/lib/kg.js +26 -9
  49. package/workspace/lib/learn.js +282 -0
  50. package/workspace/lib/live-notes.js +890 -0
  51. package/workspace/lib/proactive-cli.js +49 -1
  52. package/workspace/lib/proactive.js +8 -7
  53. package/workspace/lib/typesafe-settings.js +10 -2
  54. package/workspace/package-lock.json +1 -1
  55. package/workspace/package.json +5 -4
  56. package/workspace/public/pm/app.js +148 -79
  57. package/workspace/public/pm/data.js +58 -0
  58. package/workspace/public/pm/fixture.js +40 -2
  59. package/workspace/public/pm/modes-live.js +267 -21
  60. package/workspace/public/pm/panel-cockpit.js +636 -0
  61. package/workspace/public/pm/panel-detail.js +210 -8
  62. package/workspace/public/pm/panel-initiatives.js +9 -9
  63. package/workspace/public/pm/panel-newsess.js +145 -86
  64. package/workspace/public/pm/panel-repos.js +34 -17
  65. package/workspace/public/pm/panel-tabs.js +145 -27
  66. package/workspace/public/pm/state.js +45 -4
  67. package/workspace/public/pm/tab-doc.js +244 -16
  68. package/workspace/public/pm/tab-term.js +11 -1
  69. package/workspace/public/pm/tokens.css +4 -3
  70. package/workspace/public/pm/view-addrepo.js +207 -0
  71. package/workspace/public/pm/view-files.js +197 -0
  72. package/workspace/public/pm/view-guide.js +141 -0
  73. package/workspace/public/pm/view-knowledge.js +225 -0
  74. package/workspace/public/pm/view-memory.js +122 -0
  75. package/workspace/public/pm/view-portfolio.js +153 -0
  76. package/workspace/public/pm/view-reports.js +190 -0
  77. package/workspace/public/pm/view-scheduled.js +161 -0
  78. package/workspace/public/pm/view-skills.js +216 -0
  79. package/workspace/public/pm/view-today.js +168 -0
  80. package/workspace/public/pm/view-validate.js +171 -0
  81. package/workspace/server.js +394 -10
  82. package/workspace/skills/live-notes.skill +58 -0
  83. package/workspace/skills/morning-brief.skill +1 -0
@@ -0,0 +1,129 @@
1
+ 'use strict';
2
+ /* jev-route.js — the bridge between prompt routing and the one TypeSafe module (prompt-model-routing).
3
+ *
4
+ * The router (scripts/pmos-model.js) makes no request. This file is the only place a PROMPT's text can leave the
5
+ * machine for routing, and it does so through lib/typesafe.js and nowhere else. Like lib/jev-claims.js it opens no
6
+ * client of its own, and no-network.test.js asserts both.
7
+ *
8
+ * Three gates, all required: a saved key, D79 consent for the repository, and the "route prompts with Jev" switch
9
+ * (default OFF). With any one shut nothing is sent and the keyword routes answer. A prompt that fails the secret
10
+ * scan, or that names an excluded path, is withheld the same way. The prompt is cut to the policy's limit before it
11
+ * is sent. Jev does not treat its input as hostile (TypeSafe's own jaggedness notes), so a prompt can try to steer
12
+ * its answer: the answer only ever moves a tier, a doubtful one goes up, and a pinned route is never lowered.
13
+ *
14
+ * Jev gets four typed questions about the prompt in one request: the tier question asked in two phrasings (a
15
+ * disagreement is treated as doubt), whether a wrong answer would be costly, and whether the task is closed-set
16
+ * sorting. Only the tier, the probabilities and the version come back; nothing in the prompt is stored.
17
+ */
18
+ const typesafe = require('./typesafe');
19
+ const scanner = require('./secret-scan');
20
+ const settings = require('./typesafe-settings');
21
+ const models = require('./models');
22
+
23
+ const TIERS = ['fast', 'balanced', 'deep'];
24
+
25
+ function gates({ key, consent, route }) {
26
+ const missing = [];
27
+ if (!key) missing.push('key');
28
+ if (!consent) missing.push('consent');
29
+ if (!route) missing.push('route');
30
+ return { open: missing.length === 0, missing };
31
+ }
32
+ const GATE_WORDS = {
33
+ key: 'No TypeSafe key is saved.',
34
+ consent: 'Validation is not switched on for this repository.',
35
+ route: 'Routing prompts with Jev is off.',
36
+ };
37
+ const gateSentence = (missing) => missing.map((m) => GATE_WORDS[m]).join(' ');
38
+
39
+ /* Level 0 is the first criterion. Question A: level 2 = demanding = deep. Question B is phrased the other way round
40
+ * (how far a small fast model can be trusted), so its level 2 = fast. Two phrasings, one fact. */
41
+ const QUESTIONS = {
42
+ tier_a: { type: 'score', instructions: 'How demanding the task in the prompt is for the AI that will carry it out.',
43
+ criteria: ['Routine: sorting, listing, formatting, extracting or summarizing', 'Ordinary: drafting or reasoning that a good general model does well', 'Demanding: a spec, architecture, review, decision or anything where a subtle mistake is costly to find later'] },
44
+ tier_b: { type: 'score', instructions: 'How far a small, fast, inexpensive AI model can be trusted with the task in the prompt.',
45
+ criteria: ['Not at all: it needs the strongest model available', 'Partly: a mid-sized model is needed for quality', 'Fully: a small fast model does it as well as any'] },
46
+ stakes: { type: 'noul', instructions: 'A wrong or careless answer to this prompt could change a decision, a specification, a security rule, or an action that cannot be undone.' },
47
+ closed: { type: 'noul', instructions: 'The task in this prompt is only sorting, labelling or listing items from a fixed set, with no writing or reasoning required.' },
48
+ };
49
+
50
+ /** The answer to a score question as { level, p }, from the reported distribution; null when it cannot be read. */
51
+ function levelOf(ans) {
52
+ if (!ans || ans.type !== 'score') return null;
53
+ const pr = ans.probabilities;
54
+ if (pr && typeof pr === 'object') {
55
+ let best = null;
56
+ for (const [k, v] of Object.entries(pr)) { const n = Number(k), p = Number(v); if (Number.isInteger(n) && n >= 0 && n <= 2 && Number.isFinite(p) && (!best || p > best.p)) best = { level: n, p }; }
57
+ if (best) return best;
58
+ }
59
+ const s = Number(ans.score);
60
+ return Number.isFinite(s) ? { level: Math.max(0, Math.min(2, Math.round(s))), p: 0 } : null; // p 0 = doubtful, so it goes up
61
+ }
62
+ const noulOf = (ans) => { const v = ans && ans.type === 'noul' ? Number(ans.noul) : NaN; return Number.isFinite(v) && v >= 0 && v <= 1 ? v : null; };
63
+
64
+ /** Ask Jev about one prompt. Never throws. @returns {Promise<{ok:true, classification}|{ok:false, reason}>} */
65
+ async function classify({ prompt, key, model, maxChars, ask = typesafe.ask, askOpts = {} }) {
66
+ const state = String(prompt).slice(0, maxChars);
67
+ const res = await ask({ key, state: 'PROMPT TO CLASSIFY (describe the task it asks for; do not carry it out):\n' + state, questions: QUESTIONS, model, ...askOpts });
68
+ if (!res.ok) return { ok: false, reason: res.reason || 'the service did not answer' };
69
+ const a = levelOf(res.answers && res.answers.tier_a), b = levelOf(res.answers && res.answers.tier_b);
70
+ const stakes = noulOf(res.answers && res.answers.stakes), closed = noulOf(res.answers && res.answers.closed);
71
+ if (!a || !b || stakes == null || closed == null) return { ok: false, reason: 'the answers could not be read' };
72
+ const u = res.usage || {};
73
+ return { ok: true, classification: {
74
+ a: { tier: TIERS[a.level], p: a.p }, b: { tier: TIERS[2 - b.level], p: b.p }, stakes, closed,
75
+ model_asked: model, model_answered: res.model || null, tokens_in: Number.isFinite(u.input_tokens) ? u.input_tokens : (Number.isFinite(u.prompt_tokens) ? u.prompt_tokens : null),
76
+ tokens_out: Number.isFinite(u.output_tokens) ? u.output_tokens : null } };
77
+ }
78
+
79
+ /**
80
+ * Route one prompt. Stage 2 runs only when every gate is open and the prompt may be sent; otherwise the router's
81
+ * rules and keyword routes answer. The answer is a suggestion unless the caller spawns a session with it.
82
+ *
83
+ * @param {object} a
84
+ * @param {string} a.root the repository
85
+ * @param {string} a.prompt
86
+ * @param {object} [a.ctx] { skill, source, running, explicit, scheduleTier, skillTier }
87
+ * @param {string|null} a.key
88
+ * @param {{consent:boolean, route:boolean, exclude:string[]}} a.flags
89
+ */
90
+ async function routePrompt({ root, prompt, ctx = {}, key, flags = {}, ask = typesafe.ask, askOpts = {}, log = true }) {
91
+ const mc = models.core();
92
+ if (!mc.ok) return { ok: false, error: mc.error };
93
+ const core = mc.core;
94
+ const t0 = Date.now();
95
+ const loaded = core.loadPolicy(root);
96
+ if (!loaded.ok) return { ok: false, error: loaded.error };
97
+ const cfg = core.routerConfig(loaded.policy);
98
+ const g = gates({ key: typesafe.validKey(key), consent: !!flags.consent, route: !!flags.route });
99
+ let classification = null; let sent = false; let withheld = null; let jevError = null;
100
+ const text = String(prompt == null ? '' : prompt);
101
+ const exclude = Array.isArray(flags.exclude) ? flags.exclude : [];
102
+ // A rule (an explicit, schedule or skill tier, or a pinned route) decides before any model is asked: no request.
103
+ const ruled = core.route(root, text, ctx, null);
104
+ if (!ruled.ok) return ruled;
105
+ if (cfg.enabled && g.open && text.trim() && ruled.stage !== 'rules') {
106
+ const rules = scanner.scan(text);
107
+ const named = exclude.filter((e) => e && !e.includes('*') && text.includes(e));
108
+ if (rules.length) withheld = { why: 'secret-scan', rules };
109
+ else if (named.length) withheld = { why: 'excluded-path' };
110
+ else {
111
+ const r = await classify({ prompt: text, key, model: cfg.jev_model, maxChars: cfg.max_prompt_chars, ask, askOpts });
112
+ sent = true;
113
+ if (r.ok) classification = r.classification; else jevError = r.reason;
114
+ }
115
+ }
116
+ const r = core.route(root, text, ctx, classification);
117
+ if (!r.ok) return r;
118
+ const id = log ? core.logRoute(root, r, { router_ms: Date.now() - t0, skill: ctx.skill }) : null;
119
+ return { ...r, id, sent, withheld, jev_error: jevError, gate: g.open ? null : { missing: g.missing, sentence: gateSentence(g.missing) } };
120
+ }
121
+
122
+ /** The version named in the policy FILE, or null when the PM has not set one (so the claims check keeps its model). */
123
+ function modelFromPolicy(root) {
124
+ const mc = models.core(); if (!mc.ok) return null;
125
+ const loaded = mc.core.loadPolicy(root);
126
+ return loaded.ok && loaded.policy.router && typeof loaded.policy.router.jev_model === 'string' ? loaded.policy.router.jev_model : null;
127
+ }
128
+
129
+ module.exports = { gates, gateSentence, classify, routePrompt, modelFromPolicy, QUESTIONS };
@@ -43,7 +43,7 @@ function cacheKey(root) {
43
43
  const dirty = git(root, ['status', '--porcelain', '-z', '--untracked-files=no']).split('\0').filter(Boolean)
44
44
  .map((l) => l.slice(3)).filter((p) => /\.md$/.test(p)).sort()
45
45
  .map((p) => { try { return p + ':' + fs.statSync(path.join(root, p)).mtimeMs; } catch { return p + ':gone'; } });
46
- return crypto.createHash('sha1').update(head + '\n' + dirty.join('\n')).digest('hex');
46
+ return crypto.createHash('sha1').update('kg-v2-category\n' + head + '\n' + dirty.join('\n')).digest('hex');
47
47
  }
48
48
 
49
49
  function graphFor(root, { stateDir = defaultRoot() } = {}) {
@@ -134,7 +134,10 @@ function run(argv, { say, cwd = process.cwd(), stateDir } = {}) {
134
134
  say(`${s.nodes} files, ${s.edges} edges (${s.byBasis.stated} stated, ${s.byBasis.mention} mention) — graph ${cached ? 'from cache' : 'rebuilt'}`);
135
135
  say('edges by type: ' + Object.entries(s.byType).sort().map(([k, v]) => `${k} ${v}`).join(', '));
136
136
  say(`cards: ${s.cardsWritten} written (description), ${s.cardsDerived} derived from the first paragraph, ${s.noCard} none`);
137
- say(`${s.noType} file(s) have no type · ${s.noIncoming} have no incoming link (index and log files excluded)`);
137
+ say(`typed edges (written under a typed key): ${s.typedEdges} · inferred edges (a D<nn> mention): ${s.inferredEdges}`);
138
+ const cats = Object.entries(s.byCategory || {}).sort((x, y) => y[1] - x[1]).map(([k, v]) => `${k} ${v}`).join(' · ');
139
+ if (cats) say(`category (derived, beside type; advisory): ${cats}`);
140
+ say(`${s.noType} file(s) have no type (${s.reservedUntyped} reserved index and log files left out) · ${s.noIncoming} have no incoming link (index and log files excluded)`);
138
141
  say(`dangling links: ${s.dangling} — ${s.danglingHistory} history by design (${path.relative(root, RULES_FILE) || RULES_FILE}), ${s.danglingNeedsDecision} need a decision`);
139
142
  for (const d of s.needsDecision) say(` ${d.from} → ${d.to}`);
140
143
  say('Only relations written in the files are found. Token counts are characters ÷ 4, an estimate.');
@@ -0,0 +1 @@
1
+ { "needs_decision": 0, "why": "knowledge-graph K2 ratchet: the count of dangling links that need a decision may fall, never rise. Lower it here when you fix one; a new broken link must be fixed, or added as history by design in kg-dangling-rules.json with a reason." }
@@ -1,6 +1,7 @@
1
1
  [
2
2
  { "from": "^okf/log\\.md$", "why": "the OKF log records references to documents that were removed on purpose" },
3
3
  { "from": "^okf/products/pmos/", "to": "(mcp-tools|data-model|references/metrics/v_)", "why": "D66 deleted the hosted-backend reference documents; the text that cites them is history" },
4
+ { "from": "^planning/prd/", "to": "^/(supabase/README-repo-connect\\.md|okf/products/pmos/references/mcp-tools/link_jira_issue\\.md|okf/core/playbooks/(onboard-existing-repo|tenant-build-kickoff)\\.md)$", "why": "D66 (#294) deleted these hosted-backend documents; the PRD that cites them is history" },
4
5
  { "from": "/blind-plants/", "why": "a blind-plant specimen is planted with broken references on purpose" },
5
6
  { "to": "^/planning/prd/x\\.md$", "why": "an example path in an explainer, not a reference" }
6
7
  ]
@@ -11,7 +11,7 @@
11
11
  * WHAT IS AN EDGE. Only a relation a file writes down:
12
12
  * link a Markdown link to a tracked file basis stated
13
13
  * frontmatter a path in prd_path / rubric_path / explainer_path / sources basis stated
14
- * supersedes · amended_by · cites (typed keys a document opts into) basis stated
14
+ * supersedes · amended_by · cites · initiative (typed keys: one-line lists of paths) basis stated, typed
15
15
  * initiative initiative_id (frontmatter or an identity bullet) → its record basis stated
16
16
  * anchor parent_kr / parent_KR `health:<id>` or a KR id → the file naming it basis stated
17
17
  * cites a `D<nn>` mention in prose → that decision's ADR view basis mention
@@ -31,7 +31,7 @@
31
31
  const CARD_MAX = 240;
32
32
  const ADR_DIR = 'okf/products/pmos/adr/';
33
33
  const PATH_KEYS = ['prd_path', 'rubric_path', 'explainer_path', 'sources'];
34
- const TYPED_KEYS = ['supersedes', 'amended_by', 'cites'];
34
+ const TYPED_KEYS = ['supersedes', 'amended_by', 'cites', 'initiative'];
35
35
  const estTokens = (s) => Math.ceil(String(s || '').length / 4);
36
36
 
37
37
  /* Replace fenced code blocks and inline code spans with spaces of the same length, keeping every newline, so line
@@ -52,6 +52,8 @@
52
52
  /* Frontmatter values come from the reader workspace/lib/related-docs.js already ships (one-line `key: value`);
53
53
  * the body start line is the only thing computed here. */
54
54
  const readFm = (typeof require === 'function') ? require('./related-docs').frontmatter : null;
55
+ /* The PM-facing category (jev-doc-categorizer): derived beside `type`, never in place of it. */
56
+ const DC = (typeof require === 'function') ? require('./doc-category') : null;
55
57
  function frontmatter(text) {
56
58
  const m = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(String(text || ''));
57
59
  if (!m) return { fm: {}, bodyStart: 1 };
@@ -105,16 +107,20 @@
105
107
  for (const f of list) if (f.path.startsWith('planning/okrs/')) for (const m of f.text.matchAll(/\bO\d+\.KR\d+\b/g)) if (!krFile.has(m[0])) krFile.set(m[0], f.path);
106
108
  const budgets = have.has('planning/okrs/health-budgets.md') ? 'planning/okrs/health-budgets.md' : null;
107
109
 
110
+ const named = DC ? DC.namedFromRecords(list.filter((f) => /(^|\/)initiatives\//.test(f.path)).map((f) => ({ rel: f.path, fm: readFm ? readFm(f.text) : {} }))) : new Map();
108
111
  const nodes = [];
109
112
  const edges = new Map();
110
113
  const dangling = [];
111
114
  const meta = { decisionLines: {} };
112
- const addEdge = (from, to, type, basis) => {
115
+ const addEdge = (from, to, type, basis, typed) => {
113
116
  if (from === to || !have.has(to)) return;
114
117
  const key = from + '\u0000' + to + '\u0000' + type;
115
118
  const cur = edges.get(key);
116
- if (!cur) edges.set(key, { from, to, type, basis });
117
- else if (cur.basis === 'mention' && basis === 'stated') cur.basis = 'stated';
119
+ if (!cur) edges.set(key, typed ? { from, to, type, basis, typed: true } : { from, to, type, basis });
120
+ else {
121
+ if (cur.basis === 'mention' && basis === 'stated') cur.basis = 'stated';
122
+ if (typed) cur.typed = true;
123
+ }
118
124
  };
119
125
 
120
126
  for (const f of list) {
@@ -124,8 +130,9 @@
124
130
  const body = stripped;
125
131
  const heads = headingsOf(stripped, bodyStart);
126
132
  const title = fm.title || (heads.find((h) => h.level === 1) || {}).text || f.path;
133
+ const dc = DC ? DC.categorize({ rel: f.path, fm, text, named }) : null;
127
134
  nodes.push({
128
- id: f.path, title, type: fm.type || null, card: cardOf(fm, stripped, bodyStart),
135
+ id: f.path, title, type: fm.type || null, category: dc ? dc.category : null, category_basis: dc ? dc.basis : null, card: cardOf(fm, stripped, bodyStart),
129
136
  described: !!fm.description, tokens_est: estTokens(text), lines: text.split('\n').length,
130
137
  headings: heads.map((h) => ({ level: h.level, text: h.text, start: h.start, end: h.end })),
131
138
  status: fm.status || null, stale_after: fm.stale_after || null,
@@ -144,7 +151,10 @@
144
151
  if (to) addEdge(f.path, to, 'cites', 'mention');
145
152
  }
146
153
  for (const k of PATH_KEYS) for (const p of pathsIn(fm[k])) addEdge(f.path, p, 'frontmatter', 'stated');
147
- for (const k of TYPED_KEYS) for (const p of pathsIn(fm[k])) addEdge(f.path, p, k, 'stated');
154
+ for (const k of TYPED_KEYS) for (const p of pathsIn(fm[k])) {
155
+ if (have.has(p)) addEdge(f.path, p, k, 'stated', true);
156
+ else dangling.push({ from: f.path, to: '/' + p });
157
+ }
148
158
  const ini = fm.initiative_id || ((/^[-*]?\s*initiative_id:\s*([\w.-]+)/m.exec(body) || [])[1]);
149
159
  if (ini) addEdge(f.path, 'planning/initiatives/' + ini + '.md', 'initiative', 'stated');
150
160
  const anchor = fm.parent_kr || fm.parent_KR || ((/^[-*]?\s*parent_kr:\s*([\w:.-]+)/im.exec(body) || [])[1]);
@@ -174,18 +184,25 @@
174
184
  return { history, needs };
175
185
  }
176
186
 
187
+ /* index.md and log.md are reserved by OKF: they carry no frontmatter, so "no type" is correct for them. */
188
+ const RESERVED = /(^|\/)(index|log)\.md$/;
189
+
177
190
  function stats(graph, rules) {
178
191
  const byType = {}; const byBasis = { stated: 0, mention: 0 };
179
192
  for (const e of graph.edges) { byType[e.type] = (byType[e.type] || 0) + 1; byBasis[e.basis]++; }
180
193
  const inbound = new Set(graph.edges.map((e) => e.to));
181
- const orphans = graph.nodes.filter((n) => !inbound.has(n.id) && !/(^|\/)(index|log)\.md$/.test(n.id)).length;
194
+ const orphans = graph.nodes.filter((n) => !inbound.has(n.id) && !RESERVED.test(n.id)).length;
182
195
  const split = splitDangling(graph, rules);
183
196
  return {
184
197
  nodes: graph.nodes.length, edges: graph.edges.length, byType, byBasis,
185
198
  cardsWritten: graph.nodes.filter((n) => n.described).length,
186
199
  cardsDerived: graph.nodes.filter((n) => !n.described && n.card).length,
187
200
  noCard: graph.nodes.filter((n) => !n.card).length,
188
- noType: graph.nodes.filter((n) => !n.type).length,
201
+ noType: graph.nodes.filter((n) => !n.type && !RESERVED.test(n.id)).length,
202
+ reservedUntyped: graph.nodes.filter((n) => !n.type && RESERVED.test(n.id)).length,
203
+ byCategory: graph.nodes.reduce((m, n) => { if (n.category) m[n.category] = (m[n.category] || 0) + 1; return m; }, {}),
204
+ typedEdges: graph.edges.filter((e) => e.typed).length,
205
+ inferredEdges: graph.edges.filter((e) => e.basis === 'mention').length,
189
206
  noIncoming: orphans, dangling: graph.dangling.length,
190
207
  danglingHistory: split.history.length, danglingNeedsDecision: split.needs.length, needsDecision: split.needs,
191
208
  };
@@ -0,0 +1,282 @@
1
+ 'use strict';
2
+ /* learn.js — what the proactive engine learns from the PM's feedback (initiative `proactive-engine`, slice D4).
3
+ *
4
+ * Pure: no I/O, no clock, no model, no network. The server reads the ledgers and passes them in; this decides. The weights
5
+ * are arithmetic over the PM's own verdicts. The lessons are written from fixed templates filled with counts, because the
6
+ * workspace server may not call a model. A lesson is a proposal: it changes nothing until the PM approves it.
7
+ *
8
+ * evidence(ledgers) → the PM's verdicts, events and review decisions as signed rows, joined to their item
9
+ * weights(rows) → bounded cells per trigger, per lane, per trigger and time of day
10
+ * adjust(candidate, weights, bucket) → the points added to one item's rank score, with the reason
11
+ * rollup(rows, missed, runs, ctx) → hit rate, used-as-is rate, corrections per prepared run, missed, watermelon check
12
+ * narrowing(rows, restores, now) → triggers left off Today because they were rated badly
13
+ * propose(rows, missed, now, lessons)→ lessons from four templates; none that is waiting, approved or recently rejected
14
+ * foldLessons(events), decideLesson → the lesson ledger and the one place a state change is allowed
15
+ * snapshotDue(snapshots, ctx) → whether the weekly run is due
16
+ *
17
+ * What blocks on the PM (needs, returned) is never lowered and never narrowed.
18
+ */
19
+ const crypto = require('crypto');
20
+
21
+ const DAY = 86400000;
22
+ const CAP = Object.freeze({ kind: 8, lane: 4, time: 3, total: 15 });
23
+ const MIN_CELL = 5; // events before a cell moves
24
+ const SHRINK = 5; // a cell moves by sum / (events + 5), less than its raw average
25
+ const MIN_RATED = 5; // rated items before a hit rate is shown
26
+ const MIN_RUNS = 3; // reviewed or prepared runs before a run rate is shown
27
+ const WINDOW_DAYS = 7;
28
+ const NARROW_DAYS = 28;
29
+ const NARROW_MIN = 10;
30
+ const NARROW_FLOOR = 0.30;
31
+ const SNAPSHOT_EVERY_MS = 7 * DAY;
32
+ const REJECT_WAIT_MS = 28 * DAY;
33
+ const MAX_APPROVED = 10;
34
+ const GREEN_HIT = 0.6;
35
+ const PM_BLOCKING = Object.freeze(['needs', 'returned']);
36
+ const TRIGGERS = Object.freeze(['needs', 'returned', 'review', 'schedule', 'stalled']);
37
+ const BUCKETS = Object.freeze(['morning', 'afternoon', 'evening', 'night']);
38
+
39
+ const VALUE = Object.freeze({
40
+ verdict: { useful: 1, 'not-useful': -1, 'wrong-priority': -0.5 },
41
+ event: { acted: 0.5, dismissed: -0.5, opened: 0.25, ignored: -0.25 },
42
+ review: { accept: 1, 'send-back': -1 },
43
+ });
44
+ // Rated = the PM said something about the item itself. Opened and ignored are weak signals: they move a weight, not the hit rate.
45
+ const RATED_VERDICT = ['useful', 'not-useful', 'wrong-priority'];
46
+ const RATED_EVENT = ['acted', 'dismissed'];
47
+
48
+ const LABEL = Object.freeze({ needs: 'Open items waiting on you', returned: 'Returned initiatives', review: 'Sessions awaiting your review', schedule: 'Failed schedules', stalled: 'Stalled initiatives' });
49
+
50
+ const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
51
+ const pct = (x) => Math.round(x * 100) + '%';
52
+ const hash = (s) => crypto.createHash('sha1').update(String(s)).digest('hex');
53
+ const r1 = (x) => Math.round(x * 10) / 10;
54
+ const r2 = (x) => Math.round(x * 100) / 100;
55
+
56
+ /** Local time of day, as the PM lived it: morning 05-12, afternoon 12-17, evening 17-22, night otherwise. */
57
+ function bucketOf(ms) {
58
+ const h = new Date(ms).getHours();
59
+ return h >= 5 && h < 12 ? 'morning' : h >= 12 && h < 17 ? 'afternoon' : h >= 17 && h < 22 ? 'evening' : 'night';
60
+ }
61
+
62
+ /**
63
+ * @param {{feedback:Array, signals:Array, reviews:Array, runs:Array, queue:Array, resetAt:number}} l
64
+ * feedback proactive-feedback.jsonl rows signals signals.jsonl rows {id, kind, lane}
65
+ * reviews step-reviews.jsonl rows runs next-step-runs.jsonl rows {session_id, queue_id}
66
+ * queue folded prepare-queue entries {id, candidate_id}
67
+ * @returns {{rows:Array, missed:Array<{at:number,note:string}>}}
68
+ */
69
+ function evidence(l) {
70
+ const resetAt = Number.isFinite(l.resetAt) ? l.resetAt : 0;
71
+ const sig = new Map();
72
+ for (const s of l.signals || []) if (s && s.id) sig.set(s.id, { kind: TRIGGERS.includes(s.kind) ? s.kind : null, lane: s.lane || null });
73
+ const rows = []; const missed = [];
74
+ for (const f of l.feedback || []) {
75
+ const at = Date.parse(f && f.at);
76
+ if (!Number.isFinite(at) || at <= resetAt) continue;
77
+ if (f.kind === 'verdict' && f.verdict === 'missed') { missed.push({ at, note: clip(f.note, 120) }); continue; }
78
+ const value = f.kind === 'verdict' ? VALUE.verdict[f.verdict] : f.kind === 'event' ? VALUE.event[f.event] : undefined;
79
+ if (value === undefined) continue;
80
+ const rated = f.kind === 'verdict' ? RATED_VERDICT.includes(f.verdict) : RATED_EVENT.includes(f.event);
81
+ const s = f.candidate_id ? sig.get(f.candidate_id) : null;
82
+ rows.push({ at, source: 'feedback', cid: f.candidate_id || null, kind: s ? s.kind : null, lane: s ? s.lane : null, bucket: bucketOf(at), value, rated, matched: !!(s && s.kind) });
83
+ }
84
+ const runBySession = new Map((l.runs || []).filter((r) => r && r.session_id).map((r) => [r.session_id, r]));
85
+ const queueById = new Map((l.queue || []).filter((e) => e && e.id).map((e) => [e.id, e]));
86
+ for (const r of l.reviews || []) {
87
+ const at = Date.parse(r && r.at);
88
+ const value = VALUE.review[r && r.verdict];
89
+ if (!Number.isFinite(at) || at <= resetAt || value === undefined) continue;
90
+ const run = runBySession.get(r.session_id);
91
+ const q = run && run.queue_id ? queueById.get(run.queue_id) : null;
92
+ const s = q && q.candidate_id ? sig.get(q.candidate_id) : null;
93
+ rows.push({ at, source: 'review', cid: q ? q.candidate_id : null, kind: s ? s.kind : null, lane: s ? s.lane : null, bucket: bucketOf(at), value, rated: false, matched: !!(s && s.kind) });
94
+ }
95
+ rows.sort((a, b) => a.at - b.at);
96
+ missed.sort((a, b) => a.at - b.at);
97
+ return { rows, missed };
98
+ }
99
+
100
+ function cell(sum, n, cap) {
101
+ if (n < MIN_CELL) return { n, sum: r2(sum), delta: 0 };
102
+ return { n, sum: r2(sum), delta: r2(Math.max(-cap, Math.min(cap, cap * sum / (n + SHRINK)))) };
103
+ }
104
+
105
+ /** Bounded cells. Only an item that was matched to a trigger teaches a weight. */
106
+ function weights(rows) {
107
+ const acc = { kind: new Map(), lane: new Map(), time: new Map() };
108
+ const add = (m, k, v) => { const c = m.get(k) || { n: 0, sum: 0 }; c.n += 1; c.sum += v; m.set(k, c); };
109
+ for (const r of rows || []) {
110
+ if (!r.matched) continue;
111
+ add(acc.kind, r.kind, r.value);
112
+ if (r.lane) add(acc.lane, r.lane, r.value);
113
+ add(acc.time, r.kind + '|' + r.bucket, r.value);
114
+ }
115
+ const out = { kind: {}, lane: {}, time: {} };
116
+ for (const dim of ['kind', 'lane', 'time']) for (const [k, c] of acc[dim]) {
117
+ out[dim][k] = cell(c.sum, c.n, CAP[dim]);
118
+ // What blocks on the PM is never lowered: its own cells keep their counts but never go below zero.
119
+ if (dim !== 'lane' && PM_BLOCKING.includes(k.split('|')[0]) && out[dim][k].delta < 0) { out[dim][k].delta = 0; out[dim][k].floored = true; }
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /**
125
+ * The points added to one item's rank score. needs and returned are never lowered. `paused` gives zero.
126
+ * @returns {{delta:number, why:string[]}}
127
+ */
128
+ function adjust(c, w, bucket, paused) {
129
+ if (paused || !w || !c) return { delta: 0, why: [] };
130
+ const parts = [];
131
+ const k = w.kind[c.kind]; if (k && k.delta) parts.push({ d: k.delta, t: `${c.kind} items ${k.delta > 0 ? 'raised' : 'lowered'} ${Math.abs(k.delta)} (${k.n} ratings, net ${k.sum})` });
132
+ const ln = c.lane && w.lane[c.lane]; if (ln && ln.delta) parts.push({ d: ln.delta, t: `${c.lane} lane ${ln.delta > 0 ? 'raised' : 'lowered'} ${Math.abs(ln.delta)} (${ln.n} ratings, net ${ln.sum})` });
133
+ const tm = w.time[c.kind + '|' + bucket]; if (tm && tm.delta) parts.push({ d: tm.delta, t: `${c.kind} items in the ${bucket} ${tm.delta > 0 ? 'raised' : 'lowered'} ${Math.abs(tm.delta)} (${tm.n} ratings, net ${tm.sum})` });
134
+ let delta = Math.max(-CAP.total, Math.min(CAP.total, parts.reduce((a, p) => a + p.d, 0)));
135
+ if (PM_BLOCKING.includes(c.kind) && delta < 0) return { delta: 0, why: ['what blocks on you is never lowered'] };
136
+ return { delta: r1(delta), why: parts.map((p) => p.t) };
137
+ }
138
+
139
+ function perKind(rows, now, days) {
140
+ const from = now - days * DAY; const out = {};
141
+ for (const r of rows) {
142
+ if (!r.matched || r.source !== 'feedback' || !r.rated || r.at <= from) continue;
143
+ const c = out[r.kind] || (out[r.kind] = { n: 0, positive: 0 });
144
+ c.n += 1; if (r.value > 0) c.positive += 1;
145
+ }
146
+ for (const c of Object.values(out)) c.rate = c.n >= MIN_RATED ? r2(c.positive / c.n) : null;
147
+ return out;
148
+ }
149
+
150
+ /**
151
+ * @param {Array} rows @param {Array<{at:number,note:string}>} missed @param {number[]} preparedAt next-step run times
152
+ * @param {{now:number, days?:number}} ctx
153
+ */
154
+ function rollup(rows, missed, preparedAt, ctx) {
155
+ const days = ctx.days || WINDOW_DAYS; const from = ctx.now - days * DAY;
156
+ const win = (rows || []).filter((r) => r.at > from);
157
+ const fb = win.filter((r) => r.source === 'feedback' && r.rated);
158
+ const rev = win.filter((r) => r.source === 'review');
159
+ const prepared = (preparedAt || []).filter((t) => t > from).length;
160
+ const sendBacks = rev.filter((r) => r.value < 0).length;
161
+ const positive = fb.filter((r) => r.value > 0).length;
162
+ const accepted = rev.filter((r) => r.value > 0).length;
163
+ const missedN = (missed || []).filter((m) => m.at > from).length;
164
+ const hit = { n: fb.length, positive, rate: fb.length >= MIN_RATED ? r2(positive / fb.length) : null, min: MIN_RATED };
165
+ const asIs = { n: rev.length, accepted, rate: rev.length >= MIN_RUNS ? r2(accepted / rev.length) : null, min: MIN_RUNS };
166
+ const corrections = { n: prepared, sendBacks, rate: prepared >= MIN_RUNS ? r2(sendBacks / prepared) : null, min: MIN_RUNS };
167
+ // Watermelon: green on the headline, red underneath. Only checked when the headline is measurable and green.
168
+ const reasons = [];
169
+ const checked = hit.rate !== null && hit.rate >= GREEN_HIT;
170
+ if (checked) {
171
+ if (missedN >= 2) reasons.push(`${missedN} items were reported as missed`);
172
+ if (asIs.rate !== null && asIs.rate < 0.5) reasons.push(`only ${accepted} of ${rev.length} reviewed runs were used as they were`);
173
+ if (corrections.rate !== null && corrections.rate >= 0.5) reasons.push(`${sendBacks} of ${prepared} prepared runs were sent back`);
174
+ }
175
+ return { days, since: new Date(from).toISOString(), hit, asIs, corrections, missed: missedN, perKind: perKind(rows || [], ctx.now, days),
176
+ unmatched: fb.filter((r) => !r.matched).length,
177
+ watermelon: { checked, flag: reasons.length > 0, reasons, note: checked ? (reasons.length ? 'The hit rate looks good, and something under it does not.' : 'Nothing under the hit rate contradicts it.') : hit.rate === null ? `Not checked: fewer than ${MIN_RATED} rated items.` : `Not checked: the hit rate is under ${GREEN_HIT}, so there is no green to look under.` } };
178
+ }
179
+
180
+ /** Triggers left off Today. needs and returned never appear here. @param {Array<{kind:string, at:number}>} restores */
181
+ function narrowing(rows, restores, now) {
182
+ const last = {};
183
+ for (const r of restores || []) if (r && r.kind && Number.isFinite(r.at)) last[r.kind] = Math.max(last[r.kind] || 0, r.at);
184
+ const from = now - NARROW_DAYS * DAY; const out = [];
185
+ for (const kind of TRIGGERS) {
186
+ if (PM_BLOCKING.includes(kind)) continue;
187
+ let n = 0; let pos = 0;
188
+ for (const r of rows || []) {
189
+ if (!r.matched || r.source !== 'feedback' || !r.rated || r.kind !== kind || r.at <= from || r.at <= (last[kind] || 0)) continue;
190
+ n += 1; if (r.value > 0) pos += 1;
191
+ }
192
+ if (n >= NARROW_MIN && pos / n < NARROW_FLOOR) out.push({ kind, n, positive: pos, rate: r2(pos / n), floor: NARROW_FLOOR, days: NARROW_DAYS, label: LABEL[kind] });
193
+ }
194
+ return out;
195
+ }
196
+
197
+ /* ── lessons ───────────────────────────────────────────────────────────────────────────────────────────── */
198
+
199
+ /** The lesson ledger, folded. Events: proposed {id,key,text,evidence,at}; approved | rejected | retired {id,at}. */
200
+ function foldLessons(events) {
201
+ const by = new Map();
202
+ for (const e of Array.isArray(events) ? events : []) {
203
+ if (!e || typeof e.id !== 'string') continue;
204
+ if (e.event === 'proposed') { if (!by.has(e.id) && e.key && e.text) by.set(e.id, { id: e.id, key: e.key, text: e.text, evidence: e.evidence || '', status: 'proposed', proposed_at: e.at, at: e.at }); continue; }
205
+ const cur = by.get(e.id);
206
+ if (!cur) continue;
207
+ if (e.event === 'approved' && cur.status === 'proposed') { cur.status = 'approved'; cur.at = e.at; }
208
+ else if (e.event === 'rejected' && cur.status === 'proposed') { cur.status = 'rejected'; cur.at = e.at; }
209
+ else if (e.event === 'retired' && cur.status === 'approved') { cur.status = 'retired'; cur.at = e.at; }
210
+ }
211
+ return [...by.values()];
212
+ }
213
+
214
+ /** @returns {{ok:true, req:object}|{ok:false, error:string}} */
215
+ function checkLessonRequest(body) {
216
+ const b = body && typeof body === 'object' ? body : {};
217
+ if (b.confirm !== true) return { ok: false, error: 'confirm is required: approving a lesson lets the morning-brief session read it' };
218
+ if (typeof b.id !== 'string' || !/^ls-[0-9a-f]{10}$/.test(b.id)) return { ok: false, error: 'id is required and must be a lesson id' };
219
+ if (!['approve', 'reject', 'retire'].includes(b.decision)) return { ok: false, error: 'decision must be approve, reject or retire' };
220
+ return { ok: true, req: { id: b.id, decision: b.decision } };
221
+ }
222
+
223
+ /** @returns {string|null} the reason a decision is refused, or null */
224
+ function decideLesson(lessons, req) {
225
+ const l = (lessons || []).find((x) => x.id === req.id);
226
+ if (!l) return 'no such lesson';
227
+ if (req.decision === 'retire') return l.status === 'approved' ? null : `it is ${l.status} — only an approved lesson can be retired`;
228
+ if (l.status !== 'proposed') return `it is already ${l.status} — only a proposed lesson can be ${req.decision}d`;
229
+ if (req.decision === 'approve' && lessons.filter((x) => x.status === 'approved').length >= MAX_APPROVED) return `${MAX_APPROVED} lessons are already approved (the limit). Retire one first.`;
230
+ return null;
231
+ }
232
+
233
+ function lessonId(key, at) { return 'ls-' + hash(key + '|' + at).slice(0, 10); }
234
+
235
+ /**
236
+ * Lessons from four fixed templates, filled with counts. A key already proposed or approved is skipped, and so is a key the
237
+ * PM rejected inside the last 28 days. @returns {Array<{key:string, text:string, evidence:string}>}
238
+ */
239
+ function propose(rows, missed, now, lessons) {
240
+ const from = now - WINDOW_DAYS * DAY;
241
+ const win = (rows || []).filter((r) => r.at > from && r.matched && r.source === 'feedback' && r.rated);
242
+ const out = [];
243
+ const byKind = {}; const byCell = {};
244
+ for (const r of win) {
245
+ const k = byKind[r.kind] || (byKind[r.kind] = { n: 0, pos: 0 }); k.n += 1; if (r.value > 0) k.pos += 1;
246
+ const ck = r.kind + '|' + r.bucket; const c = byCell[ck] || (byCell[ck] = { n: 0, pos: 0 }); c.n += 1; if (r.value > 0) c.pos += 1;
247
+ }
248
+ for (const kind of TRIGGERS) {
249
+ const k = byKind[kind]; if (!k || k.n < MIN_RATED) continue;
250
+ const rate = k.pos / k.n;
251
+ const blocking = PM_BLOCKING.includes(kind); // what blocks on the PM is never pushed down, not even by a lesson
252
+ if (rate < 0.4 && !blocking) out.push({ key: 'kind-low:' + kind, text: `${LABEL[kind]} were useful ${pct(rate)} of the time this week (${k.pos} of ${k.n} ratings). Suggest: put them under worth-asking, not priorities, in the morning brief.`, evidence: `${k.pos} of ${k.n} ratings positive, ${WINDOW_DAYS} days` });
253
+ else if (rate >= 0.8) out.push({ key: 'kind-high:' + kind, text: `${LABEL[kind]} were useful ${pct(rate)} of the time this week (${k.pos} of ${k.n} ratings). Suggest: treat them as priorities in the morning brief.`, evidence: `${k.pos} of ${k.n} ratings positive, ${WINDOW_DAYS} days` });
254
+ for (const b of BUCKETS) {
255
+ const c = byCell[kind + '|' + b]; if (!c || c.n < MIN_RATED) continue;
256
+ const cr = c.pos / c.n;
257
+ if (cr < 0.3 && rate >= 0.5 && !blocking) out.push({ key: `kind-time:${kind}:${b}`, text: `${LABEL[kind]} shown in the ${b} were useful ${pct(cr)} of the time (${c.pos} of ${c.n}), against ${pct(rate)} over the whole day. Suggest: hold them until another part of the day.`, evidence: `${c.pos} of ${c.n} in the ${b}; ${k.pos} of ${k.n} overall` });
258
+ }
259
+ }
260
+ const notes = (missed || []).filter((m) => m.at > from && m.note).map((m) => m.note);
261
+ if (notes.length >= 2) {
262
+ const shown = notes.slice(0, 3).map((n) => `"${clip(n, 60)}"`).join('; ');
263
+ // One stable key, so a rejected missed lesson waits its 28 days like the others.
264
+ out.push({ key: 'missed', text: clip(`You reported ${notes.length} missed items this week: ${shown}. Suggest: look for things like these when preparing the morning brief.`, 300), evidence: `${notes.length} missed notes, ${WINDOW_DAYS} days` });
265
+ }
266
+ const live = new Set(); const wait = new Map();
267
+ for (const l of lessons || []) {
268
+ if (l.status === 'proposed' || l.status === 'approved') live.add(l.key);
269
+ if (l.status === 'rejected') wait.set(l.key, Math.max(wait.get(l.key) || 0, Date.parse(l.at) || 0));
270
+ }
271
+ return out.filter((p) => !live.has(p.key) && !(wait.has(p.key) && now - wait.get(p.key) < REJECT_WAIT_MS));
272
+ }
273
+
274
+ /** The weekly run is due when the last snapshot is 7 days old, or when there is none and there is evidence. */
275
+ function snapshotDue(snapshots, ctx) {
276
+ const last = (snapshots || []).map((s) => Date.parse(s && s.at)).filter(Number.isFinite).sort((a, b) => a - b).pop();
277
+ if (last === undefined) return !!ctx.hasEvidence;
278
+ return ctx.now - last >= SNAPSHOT_EVERY_MS;
279
+ }
280
+
281
+ module.exports = { CAP, MIN_CELL, MIN_RATED, MIN_RUNS, WINDOW_DAYS, NARROW_DAYS, NARROW_MIN, NARROW_FLOOR, SNAPSHOT_EVERY_MS, REJECT_WAIT_MS, MAX_APPROVED, GREEN_HIT,
282
+ PM_BLOCKING, TRIGGERS, BUCKETS, VALUE, LABEL, bucketOf, evidence, weights, adjust, rollup, narrowing, foldLessons, checkLessonRequest, decideLesson, lessonId, propose, snapshotDue };