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.
- package/package.json +1 -1
- package/payload/AGENTS-HUMAN.md +1 -1
- package/payload/AGENTS.md +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
- package/payload/mods/pmos-cockpit/README.md +14 -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/route.mjs +95 -0
- package/payload/mods/pmos-cockpit/hooks/register.tsx +104 -19
- package/payload/mods/pmos-cockpit/types/index.d.ts +1 -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 +214 -0
- package/payload/scripts/workflow-state.js +59 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/README.md +2 -2
- 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/index.md +2 -1
- package/workspace/guide/initiatives-and-next-steps.md +17 -2
- package/workspace/guide/knowledge-graph.md +94 -1
- package/workspace/guide/layout.md +31 -2
- package/workspace/guide/live-notes.md +78 -0
- package/workspace/guide/proactive-engine.md +6 -2
- package/workspace/guide/validate-with-typesafe.md +31 -2
- 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 +214 -0
- package/workspace/lib/engine/workflow-state.js +59 -2
- package/workspace/lib/jev-categorize.js +433 -0
- package/workspace/lib/jev-claims.js +59 -15
- package/workspace/lib/jev-live.js +37 -0
- package/workspace/lib/jev-route.js +139 -0
- package/workspace/lib/kg-cli.js +3 -1
- package/workspace/lib/kg.js +6 -1
- package/workspace/lib/learn.js +5 -3
- package/workspace/lib/live-notes.js +1035 -0
- 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 +78 -0
- package/workspace/public/pm/fixture.js +40 -2
- package/workspace/public/pm/modes-live.js +279 -18
- package/workspace/public/pm/panel-cockpit.js +638 -0
- package/workspace/public/pm/panel-detail.js +212 -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 +249 -29
- package/workspace/public/pm/state.js +48 -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-livenotes.js +397 -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 +349 -7
- package/workspace/skills/live-notes.skill +58 -0
|
@@ -0,0 +1,139 @@
|
|
|
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
|
+
/* A prompt routed from a terminal waits for its answer before it runs, so Jev gets one short try:
|
|
130
|
+
* no retries, and a few seconds. A miss falls back to the keyword route, which never moves the model. */
|
|
131
|
+
const INTERACTIVE = Object.freeze({ timeoutMs: 2500, retries: 0 });
|
|
132
|
+
|
|
133
|
+
/** The loopback address the workspace puts in each terminal's environment (PMOS_ROUTE_URL). */
|
|
134
|
+
function routeUrl(port) {
|
|
135
|
+
const n = Number(port);
|
|
136
|
+
return Number.isInteger(n) && n > 0 && n < 65536 ? `http://127.0.0.1:${n}/api/model/route` : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { gates, gateSentence, classify, routePrompt, modelFromPolicy, routeUrl, INTERACTIVE, QUESTIONS };
|
package/workspace/lib/kg-cli.js
CHANGED
|
@@ -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() } = {}) {
|
|
@@ -135,6 +135,8 @@ function run(argv, { say, cwd = process.cwd(), stateDir } = {}) {
|
|
|
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
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}`);
|
|
138
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)`);
|
|
139
141
|
say(`dangling links: ${s.dangling} — ${s.danglingHistory} history by design (${path.relative(root, RULES_FILE) || RULES_FILE}), ${s.danglingNeedsDecision} need a decision`);
|
|
140
142
|
for (const d of s.needsDecision) say(` ${d.from} → ${d.to}`);
|
package/workspace/lib/kg.js
CHANGED
|
@@ -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,6 +107,7 @@
|
|
|
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 = [];
|
|
@@ -127,8 +130,9 @@
|
|
|
127
130
|
const body = stripped;
|
|
128
131
|
const heads = headingsOf(stripped, bodyStart);
|
|
129
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;
|
|
130
134
|
nodes.push({
|
|
131
|
-
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),
|
|
132
136
|
described: !!fm.description, tokens_est: estTokens(text), lines: text.split('\n').length,
|
|
133
137
|
headings: heads.map((h) => ({ level: h.level, text: h.text, start: h.start, end: h.end })),
|
|
134
138
|
status: fm.status || null, stale_after: fm.stale_after || null,
|
|
@@ -196,6 +200,7 @@
|
|
|
196
200
|
noCard: graph.nodes.filter((n) => !n.card).length,
|
|
197
201
|
noType: graph.nodes.filter((n) => !n.type && !RESERVED.test(n.id)).length,
|
|
198
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; }, {}),
|
|
199
204
|
typedEdges: graph.edges.filter((e) => e.typed).length,
|
|
200
205
|
inferredEdges: graph.edges.filter((e) => e.basis === 'mention').length,
|
|
201
206
|
noIncoming: orphans, dangling: graph.dangling.length,
|
package/workspace/lib/learn.js
CHANGED
|
@@ -248,18 +248,20 @@ function propose(rows, missed, now, lessons) {
|
|
|
248
248
|
for (const kind of TRIGGERS) {
|
|
249
249
|
const k = byKind[kind]; if (!k || k.n < MIN_RATED) continue;
|
|
250
250
|
const rate = k.pos / k.n;
|
|
251
|
-
|
|
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` });
|
|
252
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` });
|
|
253
254
|
for (const b of BUCKETS) {
|
|
254
255
|
const c = byCell[kind + '|' + b]; if (!c || c.n < MIN_RATED) continue;
|
|
255
256
|
const cr = c.pos / c.n;
|
|
256
|
-
if (cr < 0.3 && rate >= 0.5) 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` });
|
|
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` });
|
|
257
258
|
}
|
|
258
259
|
}
|
|
259
260
|
const notes = (missed || []).filter((m) => m.at > from && m.note).map((m) => m.note);
|
|
260
261
|
if (notes.length >= 2) {
|
|
261
262
|
const shown = notes.slice(0, 3).map((n) => `"${clip(n, 60)}"`).join('; ');
|
|
262
|
-
|
|
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` });
|
|
263
265
|
}
|
|
264
266
|
const live = new Set(); const wait = new Map();
|
|
265
267
|
for (const l of lessons || []) {
|