create-pmos 0.53.0 → 0.53.2

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 (75) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/README.md +2 -0
  7. package/workspace/REMOTE.md +107 -65
  8. package/workspace/bin/pmos.js +4 -2
  9. package/workspace/first-paint-audit.js +6 -2
  10. package/workspace/fixtures/initiatives-api/list.json +13 -0
  11. package/workspace/fixtures/initiatives-api/roadmap.json +13 -0
  12. package/workspace/fixtures/report-api/draft.json +105 -0
  13. package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +12 -0
  14. package/workspace/guide/command-palette.md +1 -1
  15. package/workspace/guide/index.md +3 -1
  16. package/workspace/guide/initiatives-roadmap.md +1 -0
  17. package/workspace/guide/initiatives-screen.md +1 -0
  18. package/workspace/guide/launcher.md +51 -0
  19. package/workspace/guide/layout.md +7 -2
  20. package/workspace/guide/proactive-engine.md +21 -5
  21. package/workspace/guide/report-slides.md +28 -0
  22. package/workspace/guide/reports.md +2 -0
  23. package/workspace/lib/auto-sync.js +83 -0
  24. package/workspace/lib/board-fold.js +2 -2
  25. package/workspace/lib/breakdown-export.js +1 -6
  26. package/workspace/lib/brief.js +20 -2
  27. package/workspace/lib/goals.js +165 -0
  28. package/workspace/lib/initiative-rows.js +7 -3
  29. package/workspace/lib/launcher-cli.js +207 -0
  30. package/workspace/lib/plan-tree.js +15 -2
  31. package/workspace/lib/pmos-verbs.js +3 -0
  32. package/workspace/lib/proactive-cli.js +58 -2
  33. package/workspace/lib/proactive.js +2 -2
  34. package/workspace/lib/qr.js +293 -0
  35. package/workspace/lib/remote-access.js +184 -12
  36. package/workspace/lib/report-draft.js +34 -6
  37. package/workspace/lib/report-slides-data.js +141 -0
  38. package/workspace/lib/routes-initiatives.js +35 -2
  39. package/workspace/lib/routes-reports.js +3 -3
  40. package/workspace/lib/routes-timeline.js +1 -1
  41. package/workspace/lib/sync.js +61 -6
  42. package/workspace/package-lock.json +1 -1
  43. package/workspace/package.json +1 -1
  44. package/workspace/public/icons/apple-touch-icon.png +0 -0
  45. package/workspace/public/icons/icon-192.png +0 -0
  46. package/workspace/public/icons/icon-512.png +0 -0
  47. package/workspace/public/index.html +11 -0
  48. package/workspace/public/pm/app.js +55 -9
  49. package/workspace/public/pm/components.css +865 -1
  50. package/workspace/public/pm/dest-reports.js +8 -2
  51. package/workspace/public/pm/dest-today.js +8 -2
  52. package/workspace/public/pm/dock.js +73 -3
  53. package/workspace/public/pm/frame-copy.js +5 -0
  54. package/workspace/public/pm/initiative-list.js +18 -1
  55. package/workspace/public/pm/initiatives-copy.js +4 -0
  56. package/workspace/public/pm/panel-tabs.js +14 -12
  57. package/workspace/public/pm/print.css +11 -0
  58. package/workspace/public/pm/report-slides.js +438 -0
  59. package/workspace/public/pm/report-view.js +8 -2
  60. package/workspace/public/pm/reports-copy.js +3 -0
  61. package/workspace/public/pm/roadmap.js +23 -0
  62. package/workspace/public/pm/slides-copy.js +52 -0
  63. package/workspace/public/pm/today-copy.js +29 -0
  64. package/workspace/public/pm/today-goals.js +172 -0
  65. package/workspace/public/pm/tokens.css +13 -0
  66. package/workspace/public/pm/topbar.js +5 -2
  67. package/workspace/replaced-tests.json +36 -1
  68. package/workspace/server.js +165 -15
  69. package/workspace/skills/morning-brief.skill +15 -9
  70. package/workspace/skills/report-biweekly.skill +1 -0
  71. package/workspace/test-files.json +4 -0
  72. package/workspace/test-lists/run6.json +9 -0
  73. package/workspace/test-lists/workspace-launcher.json +5 -0
  74. package/workspace/test-lists/workspace-phone-app.json +5 -0
  75. package/workspace/test-lists/workspace-public-host.json +5 -0
@@ -16,7 +16,8 @@ The top bar has these parts, from left to right:
16
16
  - **Repository ▾** names the current repository. Press it to choose another repository, or **All repositories** to see the work of every repository at once. All repositories replaces the old Portfolio mode.
17
17
  - **Today, Initiatives, Board and Reports** are the four places. Press one to show it. The Today count shows how many items need you.
18
18
  - **Search or capture ⌘K** opens the command palette. Type to find an initiative, a repository, an open tab or a command.
19
- - **Sync** shows only when the current repository is not in sync with its remote. Press it to fetch, bring in the work of the other machine, record your changes and push. When the repository is in sync, Sync is not shown.
19
+ - **Sync** shows only when the current repository is not in sync with its remote. Press it to fetch, bring in the work of the other machine, record your changes and push. If the repository has not fetched in the last 10 minutes, Sync also shows, because a push from another machine is not visible until a fetch. When the repository fetched recently and is in sync, Sync is not shown. If Sync stops, the message names the step that stopped.
20
+ - **Sync on its own.** If you forget to press Sync, the workspace syncs for you. It syncs each repository once when the workspace starts. It also syncs a repository that has records not yet pushed when no record changed for 5 minutes. In a document repository it syncs every changed file on any branch. In a code repository it syncs only on the default branch (for example `main`), and only when every commit not yet pushed changes records and no code. If you close the tab while records are not yet pushed, the browser asks you first. To turn automatic sync off, start the workspace with `PMOS_AUTO_SYNC=0`.
20
21
  - **Settings** opens the settings of the workspace.
21
22
 
22
23
  Each place is drawn when its run is built. Until then the place says which run brings it, for example "Board arrives in Run 4."
@@ -41,6 +42,9 @@ The dock holds your terminals, documents, conversations, skills and web pages. I
41
42
  - The dock never opens by itself. A tab that opens while the dock is a rail waits in the dock until you open it.
42
43
  - **+ Session** in the dock starts a new session. Its menu also has **Session with options** and one item for each skill of the repository. A skill opens in its own tab in the dock.
43
44
  - The Web button in the dock opens a web page in a tab.
45
+ - To make the dock wider or narrower, drag its left edge. With the edge focused, the left and right arrow keys change the width in steps. Double-click the edge to go back to the default width. The width is kept in this browser.
46
+ - **Focus** in the dock gives the dock the whole window. The place and the detail are hidden, and the dock stays full width on every place. Press **Exit focus**, or ⌘⇧J (Ctrl-Shift-J), to show the place again. Folding the dock also ends Focus.
47
+ - **▲ scratchpad** in the bar under the tabs opens the scratchpad. The pad keeps one note for each terminal session, and one for the repository when no terminal is in front. **Send to terminal** types the note into the session in front.
44
48
 
45
49
  Folding the dock, or going to another place, does not touch your terminals.
46
50
 
@@ -63,6 +67,7 @@ The workspace is neutral. A primary button is the text colour on the page colour
63
67
  | g t, g i, g b, g r | Show Today, Initiatives, Board or Reports. |
64
68
  | ⌘K (Ctrl-K) | Open or close the command palette. |
65
69
  | ⌘J (Ctrl-J) | Open or fold the dock. |
70
+ | ⌘⇧J (Ctrl-Shift-J) | Focus: give the dock the whole window, or show the place again. |
66
71
  | j, k | Move down or up the rows of the place. |
67
72
  | Enter | Open the row that has the focus. |
68
73
  | Space | Tick the row. Space never opens a row. On another button of the row, for example ⋯, Space presses that button. |
@@ -77,7 +82,7 @@ A key that you type in a field, or in a terminal, goes to the field or the termi
77
82
 
78
83
  A phone shows one panel at a time. The tabs at the bottom are **Today, Initiatives, Board, Reports and Agent**. Agent shows the dock, with your terminals. Search or capture ⌘K is pinned at the top of Today.
79
84
 
80
- The bar at the top has Back (when you are deeper than a tab), Repository, the name of the screen, ⌘K, Sync (only when the repository is not in sync) and Settings. Settings opens over the current tab. Back returns to the tab.
85
+ The bar at the top has Back (when you are deeper than a tab), Repository, the name of the screen, ⌘K, Sync (when the repository is not in sync, or has not fetched in the last 10 minutes) and Settings. Settings opens over the current tab. Back returns to the tab.
81
86
 
82
87
  Every row has a visible ⋯ button. Every control is at least 44 pixels high. Nothing works only when you hover.
83
88
 
@@ -1,20 +1,35 @@
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."
5
- timestamp: 2026-10-04
4
+ description: "What the Today item shows, how your goals for the day are prepared, how the morning brief is made, and how the workspace works out what is waiting for you."
5
+ timestamp: 2026-10-06
6
6
  ---
7
7
 
8
8
  # Guide: Today and the proactive engine
9
9
 
10
10
  The today item shows what is waiting for you now. The workspace works it out by itself from the records of your repositories. You do not ask for it.
11
11
 
12
+ ## Goals for today
13
+
14
+ At the top of Today you can list what you want done today. A goal is one line of text, and it can name an initiative.
15
+
16
+ - Open **Goals for today** and type a goal. Press Enter to add it. A day has at most 7 goals, each at most 140 characters.
17
+ - Press **Done**, **Drop**, **Reopen** or **Remove** on a goal. A goal is open, done or dropped.
18
+ - Press **Prepare my day**. The workspace makes the daily 06:45 morning-brief schedule if this repository has none, and runs it now. The session reads your goals, your calendar, and the mentions of you in Slack, Jira and Confluence. It ties each meeting, mention and step that moves a goal to that goal.
19
+ - Each goal shows its steps: yours first, then the agent's. A step comes from the brief, or from a Today card on the goal's initiative. A goal with no step says so. The session does not invent a step.
20
+ - A step is under a goal only when the brief names the goal, or when the step and the goal name the same initiative. The workspace never matches words.
21
+ - An agent step that names an initiative has **Prepare**. It shows the step, the branch and the prompt, the same as background preparation. **Queue it** approves that exact prompt. Background preparation must be on in the settings. Otherwise the queue says so and nothing is queued.
22
+ - What fits no goal is listed under **Also on your plate**, and items the session is less than 0.5 sure about under **Worth asking**.
23
+ - Today also says how much free time you have until 18:00, the event coming up, and which sources were not read.
24
+ - If the brief is for another day, it is not used. Only the Today cards are shown under the goals.
25
+ - The goals are stored in the workspace state folder on this computer, one file for each day (`goals/<date>.json`). They are not committed.
26
+
12
27
  ## The morning brief
13
28
 
14
29
  The top of Today is the morning brief: a greeting, the day's calendar, the priorities and a list of things worth asking about. The brief is not computed by the workspace. A skill called morning-brief runs in an agent session. The session reads the sources, and then submits one brief with `pmos proactive brief submit`. The workspace server only checks the brief and stores it. It makes no model call and reaches no calendar or mailbox.
15
30
 
16
31
  - Press **schedule the morning brief** in Today to make a daily schedule at 06:45. The schedule is an ordinary scheduled task, so it runs only while the workspace server runs. A sleeping computer gets no brief. A schedule that is made after its time of day for the first time runs within a minute, so pressing the button at 10:00 starts one brief session straight away.
17
- - Each source is a setting: calendar, wispr, mail, jira and confluence. Mail is off by default and the skill reads only subjects and senders, never bodies. A source that is off cannot be reported as read.
32
+ - Each source is a setting: calendar, wispr, mail, slack, jira and confluence. Mail is off by default and the skill reads only subjects and senders, never bodies. Slack is on by default. In Slack, Jira and Confluence the skill reads the mentions of you: where, who, and a one-line summary. It never copies a message. A source that is off cannot be reported as read.
18
33
  - The brief names the day it is for. If that is not today, Today says it is out of date.
19
34
  - An item the session is less than 0.5 sure about is shown under worth asking, not as a priority. The workspace sets that bucket from the confidence. The session cannot choose it.
20
35
  - The order is fixed: higher confidence first, then your items before the agent's, then by title.
@@ -85,7 +100,7 @@ A verdict never changes a status, a lifecycle record or an acceptance. It is rec
85
100
  |---|---|
86
101
  | engine on | Off stops automatic scans. The scan now button still works. |
87
102
  | watch: needs, returned, review, schedule, stalled | Turn one trigger off. |
88
- | brief sources | One switch for each of calendar, wispr, mail, jira and confluence. |
103
+ | brief sources | One switch for each of calendar, wispr, mail, slack, jira and confluence. |
89
104
  | background prepare | Off by default. On lets the workspace start a session for a plan you approved. |
90
105
  | typed scoring | Off by default. On lets the skill send titles and metadata to the Typesafe service. |
91
106
  | name | Used in the greeting. |
@@ -102,6 +117,7 @@ Text that you type in a setting (the name, the stalled days, the quiet hours) or
102
117
  - `pmos proactive today` lists the items.
103
118
  - `pmos proactive scan` scans now.
104
119
  - `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.
120
+ - `pmos proactive goals` lists today's goals with their steps. `pmos proactive goals add TEXT [--initiative ID]` adds one. `done N`, `drop N`, `reopen N` and `remove N` change the Nth goal. `pmos proactive goals plan` does the same as **Prepare my day**.
105
121
  - `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.
106
122
  - `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.
107
123
  - `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.
@@ -110,7 +126,7 @@ Text that you type in a setting (the name, the stalled days, the quiet hours) or
110
126
  ## Limits
111
127
 
112
128
  - 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.
113
- - 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.
129
+ - The items from the records come from this computer. The workspace itself does not read your mail, your calendar, Slack, Jira or Confluence. Only the morning-brief session does, and only the sources you switched on.
114
130
  - 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.
115
131
  - An unreadable repository is listed as not read. An empty list is not proof that nothing waits there.
116
132
  - The records stay in the workspace state folder on this computer. They are not committed.
@@ -0,0 +1,28 @@
1
+ ---
2
+ type: Guide
3
+ title: "Report slides: present and print a report"
4
+ description: "How the Reports page shows a report as the PM-OS biweekly slides, how to present and print them, and how your notes become the words on the slides."
5
+ timestamp: 2026-10-06
6
+ ---
7
+
8
+ # Report slides
9
+
10
+ The Reports page shows a report as slides, in the PM-OS biweekly format: the roadmap snapshot, the initiative list, one slide for each initiative, and the release notes. **Present** shows them full screen. Use the arrow keys or swipe to move, and press Escape to close. **Print / PDF** prints one slide on each page.
11
+
12
+ The script writes the data of the slides at the end of the draft, in one `<!-- pmos:slides v1 … -->` block. Do not edit that block. Your words on the slides come from the notes:
13
+
14
+ - The note of an initiative is the note box on its slide. Start it with "Warning:" or "Info:" to set the type of the box. Write each point on its own line that starts with "- ". The box shows four points.
15
+ - The `report.changelog` note is the release notes. Only you write it; the agent leaves it empty. Write one entry for each release. Put a blank line after the title, after the sentence, and between entries:
16
+
17
+ ```
18
+ Launch · Sign-in: Email codes for every title
19
+
20
+ Readers can sign in with a one-time code.
21
+
22
+ - Added: Code entry screen
23
+ - Changed: The login page offers codes first
24
+ ```
25
+
26
+ The type is Launch, Improvement, Fix, Security, Deprecation or Beta. To give an entry its own date, add it after the title, for example `(2026-10-02)`. When the note is empty, the slide says that no release notes are logged.
27
+
28
+ A draft from before the slides has no slides block. Press **Redraft** to add it. Your notes stay as you wrote them.
@@ -43,6 +43,8 @@ Your note. Start it with "Warning:" when the block needs attention.
43
43
 
44
44
  The headline note is `report.headline`. The asks are in the `report.asks` note, one ask on each line, each line starting with "- ". Write prose only between the markers. Do not type numbers: the script writes them.
45
45
 
46
+ The Reports page shows a report as slides. [Report slides](report-slides.md) tells how your notes become the words on the slides.
47
+
46
48
  When you draft a slot again, every note stays as you wrote it, next to its initiative. The order of the blocks can change. A note for an initiative that is no longer in the window moves to "Notes kept from an earlier draft".
47
49
 
48
50
  ## The commands
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+ /* auto-sync — Sync without the press, for the PM who forgot to press it (D70 as amended 2026-10-06).
3
+ *
4
+ * The press (POST /api/sync) stays. This module runs the SAME lib/sync.js#sync, with every safety it has (no force,
5
+ * a conflict aborts the rebase whole, records only), at two moments and no others:
6
+ * startup once per repository when the server starts: brings in what another machine pushed.
7
+ * idle on the server's tick: a repository with work not yet pushed, quiet for QUIET_MS, is synced.
8
+ * Before either, a repository must pass every rule below. A rule that cannot be read is a no.
9
+ * - auto-sync is on for this server (see enabledFor);
10
+ * - the branch has an upstream;
11
+ * - in a CODE repository only (profile product-code, D68): the branch is the remote's default branch, and every commit
12
+ * not yet pushed touches record paths only, so a feature branch and code are never auto-pushed. A DOCUMENT
13
+ * repository (profile pm-artifacts) holds only the PM's documents: any branch, every file;
14
+ * - no record file changed in the last QUIET_MS: a document being written is left alone;
15
+ * - no sync is running for the repository, and the last auto-sync did not fail within RETRY_MS.
16
+ */
17
+ const syncLib = require('./sync');
18
+
19
+ const QUIET_MS = 5 * 60 * 1000;
20
+ const RETRY_MS = 30 * 60 * 1000;
21
+
22
+ // On for a real server start. A server given its own state directory (every suite does, through test-run.js) is a
23
+ // scratch server and does not auto-sync unless asked. PMOS_AUTO_SYNC=0 turns it off; =1 turns it on.
24
+ function enabledFor(env = process.env) {
25
+ const v = String(env.PMOS_AUTO_SYNC || '').trim();
26
+ if (v === '0') return false;
27
+ if (v === '1') return true;
28
+ return !String(env.PMOS_STATE_DIR || '').trim();
29
+ }
30
+
31
+ // Why `root` may not auto-sync now, or null when it may. `moment` is 'startup' or 'idle'.
32
+ async function blocker(root, moment, { now = Date.now(), lastFail = 0 } = {}) {
33
+ if (lastFail && now - lastFail < RETRY_MS) return 'the last auto-sync failed; it waits before trying again';
34
+ const st = await syncLib.status(root);
35
+ if (!st.ok) return st.error || 'not a git repository';
36
+ if (!st.upstream) return 'no upstream';
37
+ if (st.state === 'mid-operation') return 'a merge, rebase or cherry-pick is in progress';
38
+ const docs = syncLib.profileOf(root) === 'pm-artifacts';
39
+ const remote = st.upstream.split('/')[0];
40
+ const def = docs ? null : await syncLib.defaultBranchOf(root, remote);
41
+ if (!docs && (!def || st.branch !== def)) return `'${st.branch}' is not the default branch of ${remote}`;
42
+ if (st.ahead && !docs) {
43
+ const paths = await syncLib.unpushedPaths(root, st.upstream);
44
+ if (!paths) return 'the unpushed commits cannot be read';
45
+ const code = paths.filter((p) => !syncLib.isRecord(p));
46
+ if (code.length) return `unpushed commits change files that are not records (${code[0]}${code.length > 1 ? ', …' : ''})`;
47
+ }
48
+ if (moment === 'idle') {
49
+ if (!st.dirty_records && !st.ahead) return 'nothing to send';
50
+ const newest = await syncLib.newestDirtyRecordMs(root);
51
+ if (newest && now - newest < QUIET_MS) return 'a record changed in the last few minutes';
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /* createAutoSync({ roots, busy, onDone, env }) → { startup(), tick(), lastOf(root) }
57
+ * roots() the repository roots the server holds
58
+ * busy a Set of roots with a sync running (shared with the press route)
59
+ * onDone(root, result) called after each auto-sync that ran (to refresh caches) */
60
+ function createAutoSync({ roots, busy, onDone = () => {}, env = process.env, label = 'auto-sync' } = {}) {
61
+ const last = new Map(); // root -> { at, moment, ok, message, skipped? }
62
+ async function runFor(root, moment) {
63
+ if (!enabledFor(env) || busy.has(root)) return null;
64
+ const prev = last.get(root);
65
+ const why = await blocker(root, moment, { lastFail: prev && prev.ok === false ? prev.at : 0 });
66
+ if (why) return null;
67
+ busy.add(root);
68
+ try {
69
+ const out = await syncLib.sync(root, { label });
70
+ last.set(root, { at: Date.now(), moment, ok: !!out.ok, message: out.message, stopped_at: out.stopped_at || null });
71
+ try { onDone(root, out); } catch { /* a cache refresh is not the sync's business */ }
72
+ return out;
73
+ } finally { busy.delete(root); }
74
+ }
75
+ async function each(moment) {
76
+ const out = [];
77
+ for (const r of roots()) { try { out.push(await runFor(r, moment)); } catch { /* one repository never stops the others */ } }
78
+ return out;
79
+ }
80
+ return { startup: () => each('startup'), tick: () => each('idle'), lastOf: (root) => last.get(root) || null };
81
+ }
82
+
83
+ module.exports = { createAutoSync, blocker, enabledFor, QUIET_MS, RETRY_MS };
@@ -68,8 +68,8 @@ function workInitiatives(root) {
68
68
  for (const id of eng.listInitiatives(root)) {
69
69
  let pl;
70
70
  try { pl = planTree.profileAndLane(root, id); } catch { continue; }
71
- const def = pl.defs && pl.defs[pl.lane];
72
- if (!def || def.group !== 'work') continue;
71
+ if (pl.group !== 'work') continue;
72
+ const def = (pl.defs && pl.defs[pl.lane]) || {};
73
73
  let title = id;
74
74
  if (pl.record) {
75
75
  try { title = String((eng.parseLeadingFrontmatter(fs.readFileSync(pl.record, 'utf8')) || {}).title || '').trim() || id; } catch { title = id; }
@@ -18,7 +18,6 @@
18
18
  const fs = require('fs');
19
19
  const planItems = require('./plan-items.js');
20
20
  const planTree = require('./plan-tree.js');
21
- const rows = require('./initiative-rows.js');
22
21
  const consoleState = require('./console-state');
23
22
 
24
23
  const STATE_LABEL = { todo: 'Todo', doing: 'Doing', review: 'Review', done: 'Done' };
@@ -51,11 +50,7 @@ function exportBreakdown(root, id) {
51
50
  if (!loaded.ok) return refuse(500, loaded.error);
52
51
  let fm = {};
53
52
  try { fm = loaded.engine.parseLeadingFrontmatter(fs.readFileSync(file, 'utf8')) || {}; } catch { fm = {}; }
54
- const { lane } = planTree.profileAndLane(root, id);
55
- let groups;
56
- try { groups = rows.workGroupOf(root); } catch { groups = {}; }
57
- const declared = String(fm.lane || '').trim();
58
- if (groups[Object.prototype.hasOwnProperty.call(groups, declared) ? declared : lane] !== 'work') {
53
+ if (planTree.profileAndLane(root, id).group !== 'work') {
59
54
  return refuse(404, `${id} is not on a work lane; a document is a file and has no breakdown to export`);
60
55
  }
61
56
  const fold = planItems.readFold(root, { now: 0 });
@@ -10,12 +10,16 @@
10
10
  * greeting(now, name) → the one line Today opens with
11
11
  * localDate(now) → YYYY-MM-DD on this machine's clock
12
12
  *
13
+ * Goals (initiative `today-goals`, PRD G3): an item or an event may name one of the day's goals with `goal`. With
14
+ * `ctx.goalIds` given, a goal that is not in that day's record is refused. An item may say it is a `mention` of the PM
15
+ * (Slack, Jira, Confluence, mail): a boolean, nothing more.
16
+ *
13
17
  * The bucket is derived: an item under WORTH_ASKING confidence is "worth asking", whatever the session said. The order is
14
18
  * fixed and stated: higher confidence first, then PM-owned before agent-owned, then by title.
15
19
  */
16
20
  const crypto = require('crypto');
17
21
 
18
- const SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
22
+ const SOURCES = ['calendar', 'wispr', 'mail', 'slack', 'jira', 'confluence'];
19
23
  const ITEM_SOURCES = [...SOURCES, 'derived'];
20
24
  const SOURCE_STATES = ['read', 'not-reached', 'off'];
21
25
  const OWNERS = ['pm', 'agent'];
@@ -24,6 +28,7 @@ const WORTH_ASKING = 0.5;
24
28
  const MAX_ITEMS = 12;
25
29
  const MAX_EVENTS = 20;
26
30
  const INI_RE = /^[a-z0-9][a-z0-9._-]*$/i;
31
+ const GOAL_RE = /^g-[0-9a-f]{8}$/;
27
32
  const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
28
33
 
29
34
  const pad = (n) => String(n).padStart(2, '0');
@@ -40,6 +45,13 @@ const tooLong = (v, n) => typeof v === 'string' && v.trim().length > n;
40
45
  function validateBrief(raw, ctx = {}) {
41
46
  const now = ctx.now || Date.now();
42
47
  const settings = ctx.settings || {};
48
+ const goalIds = Array.isArray(ctx.goalIds) ? new Set(ctx.goalIds) : null;
49
+ const badGoal = (g, at) => {
50
+ if (g === undefined || g === null) return null;
51
+ if (typeof g !== 'string' || !GOAL_RE.test(g)) return `${at}: goal is not a goal id`;
52
+ if (goalIds && !goalIds.has(g)) return `${at}: goal ${g} is not one of the goals for that day`;
53
+ return null;
54
+ };
43
55
  const on = (src) => !settings.briefSources || settings.briefSources[src] !== false;
44
56
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, error: 'a brief is a JSON object' };
45
57
  const date = raw.date === undefined ? localDate(now) : raw.date;
@@ -68,7 +80,9 @@ function validateBrief(raw, ctx = {}) {
68
80
  if (typeof e.start !== 'string' || Number.isNaN(Date.parse(e.start))) return { ok: false, error: `calendar event ${i + 1}: start must be a date and time` };
69
81
  if (e.end !== undefined && (typeof e.end !== 'string' || Number.isNaN(Date.parse(e.end)))) return { ok: false, error: `calendar event ${i + 1}: end must be a date and time` };
70
82
  if (e.with !== undefined && tooLong(e.with, 120)) return { ok: false, error: `calendar event ${i + 1}: "with" is over 120 characters` };
71
- calendar.push({ start: e.start, end: e.end || null, title: clip(e.title, 120), with: e.with ? clip(e.with, 120) : null });
83
+ const gErr = badGoal(e.goal, `calendar event ${i + 1}`);
84
+ if (gErr) return { ok: false, error: gErr };
85
+ calendar.push({ start: e.start, end: e.end || null, title: clip(e.title, 120), with: e.with ? clip(e.with, 120) : null, ...(e.goal ? { goal: e.goal } : {}) });
72
86
  }
73
87
  calendar.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
74
88
 
@@ -90,11 +104,15 @@ function validateBrief(raw, ctx = {}) {
90
104
  if (!EFFORTS.includes(x.effort)) return { ok: false, error: `${at}: effort must be one of ${EFFORTS.join(', ')}` };
91
105
  if (typeof x.confidence !== 'number' || !Number.isFinite(x.confidence) || x.confidence < 0 || x.confidence > 1) return { ok: false, error: `${at}: confidence must be a number from 0 to 1` };
92
106
  if (x.initiative_id !== undefined && x.initiative_id !== null && (typeof x.initiative_id !== 'string' || !INI_RE.test(x.initiative_id))) return { ok: false, error: `${at}: initiative_id is not an initiative id` };
107
+ const gErr = badGoal(x.goal, at);
108
+ if (gErr) return { ok: false, error: gErr };
109
+ if (x.mention !== undefined && typeof x.mention !== 'boolean') return { ok: false, error: `${at}: mention must be true or false` };
93
110
  const title = clip(x.title, 140);
94
111
  items.push({
95
112
  id: crypto.createHash('sha1').update(`${date}|${x.source}|${title}`).digest('hex').slice(0, 12),
96
113
  title, why_now: clip(x.why_now, 240), evidence: clip(x.evidence, 240), source: x.source, initiative_id: x.initiative_id || null,
97
114
  owner: x.owner, effort: x.effort, confidence: x.confidence, worth_asking: x.confidence < WORTH_ASKING,
115
+ ...(x.goal ? { goal: x.goal } : {}), ...(x.mention ? { mention: true } : {}),
98
116
  });
99
117
  }
100
118
  items.sort((a, b) => b.confidence - a.confidence || (a.owner === b.owner ? 0 : a.owner === 'pm' ? -1 : 1) || a.title.localeCompare(b.title));
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+ /* goals.js — the PM's goals for the day, and the plan that joins them to what is known and what is owed (initiative
3
+ * `today-goals`, PRD G1 and G4).
4
+ *
5
+ * Pure: no I/O, no clock of its own, no model, no network. The server reads the goals file, the brief and the Today cards,
6
+ * passes them with `now`, and writes what `edit` returns.
7
+ *
8
+ * emptyDay(date) → { date, goals: [] }
9
+ * edit(day, op, { now }) → { ok:true, day } | { ok:false, error }
10
+ * op: { action: 'add', text, initiative_id? } | { action: 'done'|'drop'|'reopen'|'remove', id }
11
+ * readDay(raw, date) → a checked day record, or an empty day when `raw` is not one
12
+ * planDay({ day, brief, cards, now, endOfDay }) → the plan Today and `pmos proactive goals` show
13
+ *
14
+ * THE JOIN IS CHECKABLE. A brief item or calendar event belongs to a goal when it names the goal's id (the morning-brief
15
+ * session sets it). Failing that, an item or Today card belongs to the first open goal tied to the same initiative. There
16
+ * is no word matching: a link is an id, so the PM can see why a step is under a goal. One step is under one goal only.
17
+ */
18
+ const crypto = require('crypto');
19
+
20
+ const MAX_GOALS = 7;
21
+ const MAX_TEXT = 140;
22
+ const STATUSES = ['open', 'done', 'dropped'];
23
+ const ACTIONS = ['add', 'done', 'drop', 'reopen', 'remove'];
24
+ const INI_RE = /^[a-z0-9][a-z0-9._-]*$/i;
25
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
26
+ const GOAL_ID_RE = /^g-[0-9a-f]{8}$/;
27
+ const START_OF_DAY = '09:00';
28
+ const END_OF_DAY = '18:00';
29
+ const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
30
+
31
+ const emptyDay = (date) => ({ date, goals: [] });
32
+
33
+ /** A day record read from disk, checked. Anything that is not a day record for `date` reads as an empty day. */
34
+ function readDay(raw, date) {
35
+ if (!raw || typeof raw !== 'object' || raw.date !== date || !Array.isArray(raw.goals)) return emptyDay(date);
36
+ const goals = raw.goals.filter((g) => g && typeof g === 'object' && GOAL_ID_RE.test(g.id) && typeof g.text === 'string' && g.text.trim()
37
+ && STATUSES.includes(g.status)).slice(0, MAX_GOALS).map((g) => ({
38
+ id: g.id, text: clip(g.text, MAX_TEXT), initiative_id: typeof g.initiative_id === 'string' && INI_RE.test(g.initiative_id) ? g.initiative_id : null,
39
+ status: g.status, added_at: typeof g.added_at === 'string' ? g.added_at : null, closed_at: typeof g.closed_at === 'string' ? g.closed_at : null,
40
+ }));
41
+ return { date, goals };
42
+ }
43
+
44
+ /** One edit of the day's goals. A refused edit returns the reason and the caller writes nothing. */
45
+ function edit(day, op, { now = Date.now() } = {}) {
46
+ if (!day || !DATE_RE.test(String(day.date)) || !Array.isArray(day.goals)) return { ok: false, error: 'no day record to edit' };
47
+ if (!op || typeof op !== 'object' || !ACTIONS.includes(op.action)) return { ok: false, error: `action must be one of ${ACTIONS.join(', ')}` };
48
+ const at = new Date(now).toISOString();
49
+ const goals = day.goals.map((g) => ({ ...g }));
50
+ if (op.action === 'add') {
51
+ if (typeof op.text !== 'string' || !op.text.trim()) return { ok: false, error: 'a goal needs text' };
52
+ if (op.text.trim().length > MAX_TEXT) return { ok: false, error: `a goal is at most ${MAX_TEXT} characters` };
53
+ if (goals.length >= MAX_GOALS) return { ok: false, error: `a day has at most ${MAX_GOALS} goals. Drop or remove one first.` };
54
+ const ini = op.initiative_id == null || op.initiative_id === '' ? null : op.initiative_id;
55
+ if (ini !== null && (typeof ini !== 'string' || !INI_RE.test(ini))) return { ok: false, error: 'initiative_id is not an initiative id' };
56
+ const text = clip(op.text, MAX_TEXT);
57
+ if (goals.some((g) => g.text.toLowerCase() === text.toLowerCase())) return { ok: false, error: 'that goal is already on the list' };
58
+ const id = 'g-' + crypto.createHash('sha1').update(`${day.date}|${text}|${at}|${goals.length}`).digest('hex').slice(0, 8);
59
+ goals.push({ id, text, initiative_id: ini, status: 'open', added_at: at, closed_at: null });
60
+ return { ok: true, day: { date: day.date, goals }, goal: goals[goals.length - 1] };
61
+ }
62
+ const i = goals.findIndex((g) => g.id === op.id);
63
+ if (i < 0) return { ok: false, error: 'no goal with that id today' };
64
+ const g = goals[i];
65
+ if (op.action === 'remove') { goals.splice(i, 1); return { ok: true, day: { date: day.date, goals }, goal: g }; }
66
+ const to = { done: 'done', drop: 'dropped', reopen: 'open' }[op.action];
67
+ if (g.status === to) return { ok: false, error: `that goal is already ${to}` };
68
+ g.status = to; g.closed_at = to === 'open' ? null : at;
69
+ return { ok: true, day: { date: day.date, goals }, goal: g };
70
+ }
71
+
72
+ /* ── the plan ──────────────────────────────────────────────────────────────────────────────────────────────────── */
73
+
74
+ const minutesOf = (hhmm) => { const [h, m] = String(hhmm).split(':').map(Number); return h * 60 + m; };
75
+
76
+ /** Free minutes of the working day still ahead (from `now`, or 09:00 if earlier, to 18:00 local), and the next event. */
77
+ function freeTime(events, at, endOfDay = END_OF_DAY) {
78
+ const d = new Date(at);
79
+ const end = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, minutesOf(endOfDay)).getTime();
80
+ const now = Math.max(at, new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, minutesOf(START_OF_DAY)).getTime());
81
+ if (now >= end) return { free_minutes: 0, until: endOfDay, next: null };
82
+ const spans = [];
83
+ for (const e of events || []) {
84
+ const s = Date.parse(e.start); const f = e.end ? Date.parse(e.end) : s + 30 * 60000;
85
+ if (!Number.isFinite(s) || !Number.isFinite(f) || f <= now || s >= end) continue;
86
+ spans.push([Math.max(s, now), Math.min(f, end)]);
87
+ }
88
+ spans.sort((a, b) => a[0] - b[0]);
89
+ let busy = 0; let cur = null;
90
+ for (const sp of spans) {
91
+ if (!cur || sp[0] > cur[1]) { if (cur) busy += cur[1] - cur[0]; cur = sp.slice(); }
92
+ else cur[1] = Math.max(cur[1], sp[1]);
93
+ }
94
+ if (cur) busy += cur[1] - cur[0];
95
+ const upcoming = (events || []).filter((e) => Date.parse(e.start) > now).sort((a, b) => Date.parse(a.start) - Date.parse(b.start))[0] || null;
96
+ return { free_minutes: Math.max(0, Math.round((end - now - busy) / 60000)), until: endOfDay, next: upcoming ? { start: upcoming.start, title: upcoming.title } : null };
97
+ }
98
+
99
+ const stepOrder = (a, b) => (a.owner === b.owner ? 0 : a.owner === 'pm' ? -1 : 1) || (b.confidence - a.confidence) || a.title.localeCompare(b.title);
100
+
101
+ function itemStep(x) {
102
+ return { ref: 'brief:' + x.id, from: 'brief', source: x.source, title: x.title, why: x.why_now, evidence: x.evidence || '', owner: x.owner,
103
+ effort: x.effort, confidence: x.confidence, initiative_id: x.initiative_id || null, mention: !!x.mention, worth_asking: !!x.worth_asking,
104
+ prepare: x.owner === 'agent' && !!x.initiative_id };
105
+ }
106
+ function cardStep(c) {
107
+ return { ref: 'today:' + c.id, from: 'today', source: c.source || 'today', title: c.cause, why: c.cause, evidence: c.evidence || '', owner: 'pm',
108
+ effort: null, confidence: 1, initiative_id: c.initiative_id || null, kind: c.kind, act: c.act ? c.act.label : null, ws: c.ws || null, mention: false,
109
+ worth_asking: false, prepare: false };
110
+ }
111
+
112
+ /**
113
+ * The day plan.
114
+ * day the goals record for the day (readDay)
115
+ * brief the stored brief (lib/brief.js shape) or null
116
+ * cards Today cards (lib/today.js) across the workspaces read, or []
117
+ * now ms; the clock the server passes
118
+ * A brief for another day is not joined: it is named as out of date, and only the Today cards plan the goals.
119
+ */
120
+ function planDay({ day, brief = null, cards = [], now = Date.now(), endOfDay = END_OF_DAY } = {}) {
121
+ const date = day && day.date;
122
+ const fresh = !!brief && brief.date === date;
123
+ const items = fresh ? [...(brief.priorities || []), ...(brief.worth_asking || [])] : [];
124
+ const events = fresh ? (brief.calendar || []) : [];
125
+ const goals = (day && day.goals ? day.goals : []).map((g) => ({ ...g, steps: [], context: [] }));
126
+ const open = goals.filter((g) => g.status === 'open');
127
+ const byId = new Map(goals.map((g) => [g.id, g]));
128
+ const byIni = (ini) => (ini ? open.find((g) => g.initiative_id === ini) || null : null);
129
+ const unplaced = { items: [], cards: [] };
130
+
131
+ for (const x of items) {
132
+ const g = (x.goal && byId.get(x.goal)) || byIni(x.initiative_id);
133
+ if (g) g.steps.push(itemStep(x)); else unplaced.items.push(x);
134
+ }
135
+ for (const c of cards || []) {
136
+ const g = byIni(c.initiative_id);
137
+ if (g) g.steps.push(cardStep(c)); else unplaced.cards.push(c);
138
+ }
139
+ for (const e of events) {
140
+ const g = e.goal && byId.get(e.goal);
141
+ if (g) g.context.push({ start: e.start, end: e.end || null, title: e.title, with: e.with || null });
142
+ }
143
+ for (const g of goals) {
144
+ g.steps.sort(stepOrder);
145
+ g.coverage = g.status === 'done' ? 'done' : g.status === 'dropped' ? 'dropped' : g.steps.length ? 'planned' : 'needs-plan';
146
+ g.agent_steps = g.steps.filter((s) => s.prepare).length;
147
+ }
148
+ const also = [
149
+ ...unplaced.items.filter((x) => !x.worth_asking).map(itemStep),
150
+ ...unplaced.cards.map(cardStep),
151
+ ].sort(stepOrder);
152
+ const worthAsking = unplaced.items.filter((x) => x.worth_asking).map(itemStep).sort(stepOrder);
153
+ const notRead = fresh ? Object.entries(brief.sources || {}).filter(([, v]) => v !== 'read').map(([k, v]) => ({ source: k, state: v })) : [];
154
+ const counts = { open: open.length, done: goals.filter((g) => g.status === 'done').length, planned: goals.filter((g) => g.coverage === 'planned').length,
155
+ needs_plan: goals.filter((g) => g.coverage === 'needs-plan').length };
156
+ return {
157
+ date, goals, also, worth_asking: worthAsking, counts,
158
+ brief: brief ? { date: brief.date, fresh, generated_at: brief.generated_at || null } : null,
159
+ not_read: notRead,
160
+ time: fresh ? freeTime(events, now, endOfDay) : null,
161
+ mentions: items.filter((x) => x.mention).length,
162
+ };
163
+ }
164
+
165
+ module.exports = { MAX_GOALS, MAX_TEXT, STATUSES, ACTIONS, GOAL_ID_RE, START_OF_DAY, END_OF_DAY, emptyDay, readDay, edit, freeTime, planDay };
@@ -60,10 +60,14 @@ const iso = (v) => {
60
60
 
61
61
  /** The engine's JSON rows → the narrowed rows, each with its lane group. */
62
62
  function narrow(root, raw) {
63
- let groups = {};
64
- try { groups = workGroupOf(root); } catch { groups = {}; }
63
+ // The group comes from the lane the record DECLARES (plan-tree.js viewGroup): the engine's `lane` is the fallback
64
+ // when none is declared, and in a pm-artifacts repository that fallback is the document lane `prd`.
65
+ const planTree = require('./plan-tree.js');
66
+ const groupOf = (r) => {
67
+ try { return planTree.profileAndLane(root, r.id).group; } catch { return 'work'; }
68
+ };
65
69
  return (Array.isArray(raw) ? raw : []).map((r) => (r && r.ok !== false ? {
66
- ok: true, id: r.id, title: r.title || r.id, lane: r.lane, group: groups[r.lane] || null, anchor: r.anchor || null,
70
+ ok: true, id: r.id, title: r.title || r.id, lane: r.lane, group: groupOf(r), anchor: r.anchor || null,
67
71
  stage: r.stage || null, accepted: !!r.accepted,
68
72
  stopped: r.stopped ? { state: r.stopped.state, reason: r.stopped.reason || '', at: r.stopped.at || null } : null,
69
73
  last_activity: iso(r.last_activity), profile: r.profile || null, record: r.record || null, next: r.next || null,