create-pmos 0.46.0 → 0.47.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 (33) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
  4. package/payload/mods/pmos-cockpit/README.md +9 -1
  5. package/payload/mods/pmos-cockpit/hooks/lib/handover-run.mjs +33 -0
  6. package/payload/mods/pmos-cockpit/hooks/lib/handover.mjs +267 -0
  7. package/payload/mods/pmos-cockpit/hooks/register.tsx +152 -0
  8. package/payload/mods/pmos-cockpit/types/index.d.ts +16 -1
  9. package/payload/okf/core/concepts/frontmatter-format.md +6 -1
  10. package/payload/planning/evals/eval-calibration.md +1 -1
  11. package/payload/planning/okrs/health-budgets.md +1 -1
  12. package/payload/skills/SKILL-FORMAT.md +1 -0
  13. package/payload/state/discoveries.md +1 -1
  14. package/workspace/README.md +1 -1
  15. package/workspace/guide/initiatives-and-next-steps.md +1 -0
  16. package/workspace/guide/knowledge-graph.md +6 -1
  17. package/workspace/guide/layout.md +1 -0
  18. package/workspace/guide/proactive-engine.md +21 -3
  19. package/workspace/guide/scheduled-tasks-and-cli.md +1 -0
  20. package/workspace/guide/statuses.md +1 -0
  21. package/workspace/guide/validate-with-typesafe.md +1 -0
  22. package/workspace/lib/kg-cli.js +2 -1
  23. package/workspace/lib/kg-dangling-baseline.json +1 -0
  24. package/workspace/lib/kg-dangling-rules.json +1 -0
  25. package/workspace/lib/kg.js +20 -8
  26. package/workspace/lib/learn.js +280 -0
  27. package/workspace/lib/proactive-cli.js +49 -1
  28. package/workspace/lib/proactive.js +8 -7
  29. package/workspace/package-lock.json +1 -1
  30. package/workspace/package.json +3 -3
  31. package/workspace/public/pm/modes-live.js +52 -4
  32. package/workspace/server.js +104 -3
  33. package/workspace/skills/morning-brief.skill +1 -0
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  type: Reference
3
3
  title: "SKILL-FORMAT.md — Required Format for PMOS Skills"
4
+ description: "The required structure of every .skill file in PMOS, and why it keeps skills cheap to keep available and expensive to load only when used."
4
5
  timestamp: 2026-07-23
5
6
  ---
6
7
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-04T09:45:16Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-04T10:38:12Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -76,7 +76,7 @@ After the session ends, `GET /api/next-step/summary` returns the files the branc
76
76
 
77
77
  ## Today: what is waiting for you (`proactive-engine`, slice D1)
78
78
 
79
- `lib/proactive.js` is a pure core: it reads the records the workspace already derives and finds five conditions: a need waiting on the PM, an initiative returned at acceptance, an ended next-step session with no review, a scheduled task whose last run failed, and an active initiative with no agent run for the stalled threshold (5 days by default). The ranking is fixed and stated in the guide. `GET /api/proactive/today` computes the list on each request. A scan (`POST /api/proactive/scan`, and a 10-minute automatic tick inside the existing 60-second interval) appends new items once a day to `signals.jsonl`. Feedback (`useful`, `not-useful`, `wrong-priority`, `missed` with a note, and the events `opened`, `dismissed`) is appended to `proactive-feedback.jsonl`; a dismissed item is hidden for 7 days. Settings are in `proactive-settings.json` (engine on or off, one trigger off, the stalled threshold, quiet hours, pause learning; the last has no effect yet because nothing learns from the verdicts in this slice). The same actions are in `pmos proactive today|scan|settings|feedback` (`lib/proactive-cli.js`), a thin client of those routes. The list is drawn in the navigation column by the `today` drawer item. It runs only while the server runs, reads no mail or calendar, and starts no session, sends nothing and changes no record of an initiative (`proactive.test.js` E25-E26 read the source for that). Tests: `proactive.test.js` (pure core, routes, CLI) and `pm-r22-test.js` (the page). The guide page is `guide/proactive-engine.md`.
79
+ `lib/proactive.js` is a pure core: it reads the records the workspace already derives and finds five conditions: a need waiting on the PM, an initiative returned at acceptance, an ended next-step session with no review, a scheduled task whose last run failed, and an active initiative with no agent run for the stalled threshold (5 days by default). The ranking is fixed and stated in the guide. `GET /api/proactive/today` computes the list on each request. A scan (`POST /api/proactive/scan`, and a 10-minute automatic tick inside the existing 60-second interval) appends new items once a day to `signals.jsonl`. Feedback (`useful`, `not-useful`, `wrong-priority`, `missed` with a note, and the events `opened`, `dismissed`) is appended to `proactive-feedback.jsonl`; a dismissed item is hidden for 7 days. Settings are in `proactive-settings.json` (engine on or off, one trigger off, the stalled threshold, quiet hours, pause learning). The same actions are in `pmos proactive today|scan|settings|feedback` (`lib/proactive-cli.js`), a thin client of those routes. The list is drawn in the navigation column by the `today` drawer item. It runs only while the server runs, reads no mail or calendar, and starts no session, sends nothing and changes no record of an initiative (`proactive.test.js` E25-E26 read the source for that). Tests: `proactive.test.js` (pure core, routes, CLI) and `pm-r22-test.js` (the page). The guide page is `guide/proactive-engine.md`. **Learning (D4).** `lib/learn.js` is a second pure core. It joins the PM's verdicts, events and step reviews to items through the candidate id in `signals.jsonl` (which now records the initiative's lane), moves three bounded weights (per trigger, per lane, per trigger and time of day; caps 8, 4, 3 and 15 in all, no cell before 5 events) that `rank` adds to the fixed score, reports a 7-day rollup with every count and a watermelon check, leaves a trigger off Today when it was rated useful under 30% of the time over 10 or more ratings in 28 days, and proposes lessons from four templates. A lesson is approved, rejected or retired only by the PM's press; an approved lesson is read by the morning-brief session through `pmos proactive learn lessons` and goes into no other prompt, so the C1 prompt and plan hash are unchanged. The weekly review runs from the existing 10-minute proactive tick and does not catch up. needs and returned are never lowered or narrowed. Pause learning gives zero weights and no review. Ledgers: `learn-events.jsonl`, `lessons.jsonl`, `proactive-rollups.jsonl`. Routes `GET /api/proactive/learn` and `POST /api/proactive/learn/{run,lesson,restore,reset}`; commands `pmos proactive learn …`. Tests: `learn.test.js` and `pm-r26-test.js`.
80
80
 
81
81
  ## The morning brief (`proactive-engine`, slice D2)
82
82
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: initiatives and next steps"
4
+ description: "What the initiative detail shows, top to bottom: status, lifecycle control, and the next step the workspace proposes."
4
5
  timestamp: 2026-10-03
5
6
  ---
6
7
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: find knowledge without reading whole files"
4
+ description: "How to use pmos kg to find one card, section or decision without reading whole files, and the limits of the map."
4
5
  timestamp: 2026-10-03
5
6
  ---
6
7
 
@@ -18,7 +19,7 @@ The map has an **edge** only where a file states a relation:
18
19
  |---|---|
19
20
  | link | A Markdown link to a tracked file. |
20
21
  | frontmatter | A path in `prd_path`, `rubric_path`, `explainer_path` or `sources`. |
21
- | supersedes, amended_by, cites | A document that chooses to carry one of these keys. |
22
+ | supersedes, amended_by, cites, initiative | A document that chooses to carry one of these keys. |
22
23
  | initiative | An `initiative_id` that names an initiative record. |
23
24
  | anchor | A `parent_kr` of `health:<id>` or a KR id. |
24
25
  | cites (mention) | A `D<nn>` in the text, such as `D65`, with two or more digits. |
@@ -27,6 +28,10 @@ A link that points at a missing file is **dangling**. It is listed and never bec
27
28
 
28
29
  It does not guess. A file that talks about a topic without a link, a path or a `D<nn>` is not connected to it. It does not use embeddings or a model, and it does not use the network.
29
30
 
31
+ ## Typed keys
32
+
33
+ A document may carry `supersedes`, `amended_by`, `cites` and `initiative` in its frontmatter. Each key is one line and holds one or more paths to `.md` files, separated by commas. The map reads each path as a stated edge of that type. A path to a file that does not exist is listed as dangling and never becomes an edge. Nobody fills these keys in for existing documents; add them to new or edited ones. `pmos kg stats` shows how many edges are typed and how many are inferred from a `D<nn>` in the text.
34
+
30
35
  ## Commands
31
36
 
32
37
  | Command | What it does |
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: layout"
4
+ description: "The three workspace columns: navigation, detail and tabs, and what each one holds."
4
5
  timestamp: 2026-10-03
5
6
  ---
6
7
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: Today and the proactive engine"
4
+ description: "What the Today item shows, how the morning brief is made, and how the workspace works out what is waiting for you."
4
5
  timestamp: 2026-10-03
5
6
  ---
6
7
 
@@ -39,6 +40,22 @@ From a Today item that names an initiative, you can ask for the plan and approve
39
40
 
40
41
  The states of an entry are queued, started, skipped, expired and cancelled.
41
42
 
43
+ ## What it learns
44
+
45
+ Today learns from you, and only from you. The evidence is your own records on this computer: the verdict you give an item, what you do with it (opened, acted, dismissed, ignored), and your accept or send-back on a prepared session. The workspace makes no model call to do this. The weights are arithmetic, and the lessons are written from fixed templates filled with counts.
46
+
47
+ - **Weights.** Three adjustments are added to an item's place in the order: one for its trigger (at most 8 points), one for its lane (at most 4) and one for its trigger at this time of day (at most 3). Time of day is morning 05-12, afternoon 12-17, evening 17-22 and night, in your local time. A cell moves only after 5 events, and moves less than the raw average. The total is at most 15, so learning reorders items that are close and cannot turn the stated order upside down. An item shows its adjustment and the counts behind it.
48
+ - **What it never does.** It never lowers or hides what blocks on you: the needs and returned triggers get no negative adjustment and are never narrowed. Items of the morning brief teach no weight, because their order is fixed. Pause learning makes every weight zero and stops the weekly review, the proposals and the narrowing; feedback is still recorded.
49
+ - **The weekly review.** About every 7 days, only while the workspace server runs, the workspace takes one snapshot. It does not catch up after a gap: it takes one snapshot of the last 7 days. **run the weekly review now** does the same on demand and is refused while learning is paused. Quiet hours hold scans, not this review.
50
+ - **The rollup.** For the last 7 days: the hit rate (useful and acted, over useful, acted, not useful, wrong priority and dismissed), the used-as-is rate (accepted over accepted and sent back), corrections per prepared run (sends-back over every session started from a step, reviewed or not), and the items you reported as missed. Every rate shows its count. With fewer than 5 rated items, or 3 runs, it says there is not enough evidence and shows no rate.
51
+ - **The watermelon check.** It looks only when the hit rate is 0.6 or more. It then names any of these: 2 or more items reported as missed, a used-as-is rate under 0.5 over 3 or more reviewed runs, or 0.5 or more corrections per prepared run over 3 or more runs. A good hit rate with one of these under it is not a good week.
52
+ - **Lessons.** The review proposes lessons from four templates: a trigger that was useful under 40% or at least 80% of the time (5 or more ratings), items you reported as missed (2 or more), and a trigger that did badly in one part of the day only. A lesson is a proposal. It is approved, rejected or retired only when you press the button. A rejected lesson is not proposed again for 28 days. At most 10 lessons are approved at once.
53
+ - **Where an approved lesson goes.** Into the state folder, and from there to the morning-brief session, which reads it with `pmos proactive learn lessons`. It goes into no other prompt, so the prompt and the plan hash of a prepared step do not change, and no file of any repository is written. To keep a lesson in repository memory, copy it there yourself.
54
+ - **Narrowing.** A trigger other than needs and returned that you rated useful under 30% of the time over 10 or more ratings in 28 days is left off Today. Today says so, with the numbers and a **restore** button. The trigger is still scanned. A restore holds until 10 new ratings of that trigger have arrived.
55
+ - **forget what it learned** makes the workspace ignore all earlier feedback from then on. Nothing is deleted.
56
+
57
+ The records are `learn-events.jsonl` (restore and reset), `lessons.jsonl` and `proactive-rollups.jsonl`, in the workspace state folder. They are not committed.
58
+
42
59
  ## What it shows
43
60
 
44
61
  Today scans for five conditions. Each one becomes one item with the reason and the evidence.
@@ -60,7 +77,7 @@ The order is fixed and you can read it: what blocks on you first, then an unrevi
60
77
  - dismiss 7 days hides the item for 7 days.
61
78
  - something was missed records a note about what Today should have shown. It needs a note.
62
79
 
63
- A verdict never changes a status, a lifecycle record or an acceptance. In this release a verdict is only recorded. Nothing uses it to change the order yet.
80
+ A verdict never changes a status, a lifecycle record or an acceptance. It is recorded, and it is evidence for what Today learns, as described above.
64
81
 
65
82
  ## Settings
66
83
 
@@ -74,7 +91,7 @@ A verdict never changes a status, a lifecycle record or an acceptance. In this r
74
91
  | name | Used in the greeting. |
75
92
  | stalled after N days | The stalled threshold, from 1 to 90 days. |
76
93
  | quiet hours | A start and an end time. No automatic scan runs between them. |
77
- | pause learning | Saved for later. Nothing learns from your verdicts in this release, so it changes nothing today. |
94
+ | pause learning | Stops all learning: weights are zero, and there is no weekly review, no proposal and no narrowing. Feedback is still recorded. |
78
95
 
79
96
  ## The commands
80
97
 
@@ -83,12 +100,13 @@ A verdict never changes a status, a lifecycle record or an acceptance. In this r
83
100
  - `pmos proactive settings` shows the settings. Add `--enabled`, `--trigger NAME=on|off`, `--stalled-days`, `--quiet HH:MM-HH:MM|none`, `--pause-learning`, `--background-prepare on|off`, `--brief-source NAME=on|off`, `--brief-scoring on|off` or `--name` to change one.
84
101
  - `pmos proactive brief` shows today's brief. `pmos proactive brief submit --file PATH` submits one. `pmos proactive brief schedule` makes the 06:45 schedule for the current repository.
85
102
  - `pmos proactive prepare` shows the queue. `pmos proactive prepare plan --initiative ID` shows a plan and its hash. `pmos proactive prepare queue --initiative ID --item ITEM --sha HASH` approves it. `pmos proactive prepare cancel ID` cancels an entry. `pmos proactive prepare run` looks at the queue now.
103
+ - `pmos proactive learn` shows the rollup, the weights, the narrowed triggers and the lessons. `pmos proactive learn run` takes the weekly review now. `pmos proactive learn lessons` lists the approved lessons. `pmos proactive learn approve ID`, `reject ID` and `retire ID` decide a lesson. `pmos proactive learn restore TRIGGER` brings back a narrowed trigger. `pmos proactive learn reset` makes it ignore earlier feedback.
86
104
  - `pmos proactive feedback <id> useful|not-useful|wrong-priority|opened|dismissed` records feedback. Use `--missed "text"` for a missed item.
87
105
 
88
106
  ## Limits
89
107
 
90
108
  - It runs only while the workspace server runs. It does not catch up for time the server was off. A sleeping computer gets no scan.
91
109
  - The items from the records come from this computer. The workspace itself does not read your mail or your calendar. Only the morning-brief session does, and only the sources you switched on.
92
- - It does not start a session, push, send, post, accept or merge. It only shows items and records your feedback. The one exception is background preparation, which starts a session only for a plan you approved, as described above.
110
+ - It does not start a session, push, send, post, accept or merge. It only shows items and records your feedback, and learns from it as described above. The one exception is background preparation, which starts a session only for a plan you approved, as described above.
93
111
  - An unreadable repository is listed as not read. An empty list is not proof that nothing waits there.
94
112
  - The records stay in the workspace state folder on this computer. They are not committed.
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: scheduled tasks and the command line"
4
+ description: "How scheduled tasks run a skill in a terminal session, their limits, and the command line that manages them."
4
5
  timestamp: 2026-10-03
5
6
  ---
6
7
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: statuses"
4
+ description: "The nine initiative statuses, who acts on each, and why a status is worked out again each time and never typed."
4
5
  timestamp: 2026-10-03
5
6
  ---
6
7
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: validate a repository"
4
+ description: "How the validate item checks that a repository agrees with itself, and the optional second check with TypeSafe."
4
5
  timestamp: 2026-10-03
5
6
  ---
6
7
 
@@ -134,7 +134,8 @@ 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
+ 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
139
  say(`dangling links: ${s.dangling} — ${s.danglingHistory} history by design (${path.relative(root, RULES_FILE) || RULES_FILE}), ${s.danglingNeedsDecision} need a decision`);
139
140
  for (const d of s.needsDecision) say(` ${d.from} → ${d.to}`);
140
141
  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
@@ -109,12 +109,15 @@
109
109
  const edges = new Map();
110
110
  const dangling = [];
111
111
  const meta = { decisionLines: {} };
112
- const addEdge = (from, to, type, basis) => {
112
+ const addEdge = (from, to, type, basis, typed) => {
113
113
  if (from === to || !have.has(to)) return;
114
114
  const key = from + '\u0000' + to + '\u0000' + type;
115
115
  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';
116
+ if (!cur) edges.set(key, typed ? { from, to, type, basis, typed: true } : { from, to, type, basis });
117
+ else {
118
+ if (cur.basis === 'mention' && basis === 'stated') cur.basis = 'stated';
119
+ if (typed) cur.typed = true;
120
+ }
118
121
  };
119
122
 
120
123
  for (const f of list) {
@@ -144,7 +147,10 @@
144
147
  if (to) addEdge(f.path, to, 'cites', 'mention');
145
148
  }
146
149
  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');
150
+ for (const k of TYPED_KEYS) for (const p of pathsIn(fm[k])) {
151
+ if (have.has(p)) addEdge(f.path, p, k, 'stated', true);
152
+ else dangling.push({ from: f.path, to: '/' + p });
153
+ }
148
154
  const ini = fm.initiative_id || ((/^[-*]?\s*initiative_id:\s*([\w.-]+)/m.exec(body) || [])[1]);
149
155
  if (ini) addEdge(f.path, 'planning/initiatives/' + ini + '.md', 'initiative', 'stated');
150
156
  const anchor = fm.parent_kr || fm.parent_KR || ((/^[-*]?\s*parent_kr:\s*([\w:.-]+)/im.exec(body) || [])[1]);
@@ -174,18 +180,24 @@
174
180
  return { history, needs };
175
181
  }
176
182
 
183
+ /* index.md and log.md are reserved by OKF: they carry no frontmatter, so "no type" is correct for them. */
184
+ const RESERVED = /(^|\/)(index|log)\.md$/;
185
+
177
186
  function stats(graph, rules) {
178
187
  const byType = {}; const byBasis = { stated: 0, mention: 0 };
179
188
  for (const e of graph.edges) { byType[e.type] = (byType[e.type] || 0) + 1; byBasis[e.basis]++; }
180
189
  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;
190
+ const orphans = graph.nodes.filter((n) => !inbound.has(n.id) && !RESERVED.test(n.id)).length;
182
191
  const split = splitDangling(graph, rules);
183
192
  return {
184
193
  nodes: graph.nodes.length, edges: graph.edges.length, byType, byBasis,
185
194
  cardsWritten: graph.nodes.filter((n) => n.described).length,
186
195
  cardsDerived: graph.nodes.filter((n) => !n.described && n.card).length,
187
196
  noCard: graph.nodes.filter((n) => !n.card).length,
188
- noType: graph.nodes.filter((n) => !n.type).length,
197
+ noType: graph.nodes.filter((n) => !n.type && !RESERVED.test(n.id)).length,
198
+ reservedUntyped: graph.nodes.filter((n) => !n.type && RESERVED.test(n.id)).length,
199
+ typedEdges: graph.edges.filter((e) => e.typed).length,
200
+ inferredEdges: graph.edges.filter((e) => e.basis === 'mention').length,
189
201
  noIncoming: orphans, dangling: graph.dangling.length,
190
202
  danglingHistory: split.history.length, danglingNeedsDecision: split.needs.length, needsDecision: split.needs,
191
203
  };
@@ -0,0 +1,280 @@
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
+ if (rate < 0.4) 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
+ 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
+ for (const b of BUCKETS) {
254
+ const c = byCell[kind + '|' + b]; if (!c || c.n < MIN_RATED) continue;
255
+ 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
+ }
258
+ }
259
+ const notes = (missed || []).filter((m) => m.at > from && m.note).map((m) => m.note);
260
+ if (notes.length >= 2) {
261
+ const shown = notes.slice(0, 3).map((n) => `"${clip(n, 60)}"`).join('; ');
262
+ out.push({ key: 'missed:' + hash(notes.slice().sort().join('|')).slice(0, 10), 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
+ }
264
+ const live = new Set(); const wait = new Map();
265
+ for (const l of lessons || []) {
266
+ if (l.status === 'proposed' || l.status === 'approved') live.add(l.key);
267
+ if (l.status === 'rejected') wait.set(l.key, Math.max(wait.get(l.key) || 0, Date.parse(l.at) || 0));
268
+ }
269
+ return out.filter((p) => !live.has(p.key) && !(wait.has(p.key) && now - wait.get(p.key) < REJECT_WAIT_MS));
270
+ }
271
+
272
+ /** The weekly run is due when the last snapshot is 7 days old, or when there is none and there is evidence. */
273
+ function snapshotDue(snapshots, ctx) {
274
+ const last = (snapshots || []).map((s) => Date.parse(s && s.at)).filter(Number.isFinite).sort((a, b) => a - b).pop();
275
+ if (last === undefined) return !!ctx.hasEvidence;
276
+ return ctx.now - last >= SNAPSHOT_EVERY_MS;
277
+ }
278
+
279
+ 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,
280
+ PM_BLOCKING, TRIGGERS, BUCKETS, VALUE, LABEL, bucketOf, evidence, weights, adjust, rollup, narrowing, foldLessons, checkLessonRequest, decideLesson, lessonId, propose, snapshotDue };