create-pmos 0.67.1 → 0.69.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 (37) 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/guide/command-palette.md +1 -1
  7. package/workspace/guide/initiatives-screen.md +1 -1
  8. package/workspace/guide/layout.md +35 -8
  9. package/workspace/guide/notes.md +12 -1
  10. package/workspace/guide/today.md +3 -1
  11. package/workspace/lib/notes-links.js +56 -0
  12. package/workspace/lib/notes.js +87 -5
  13. package/workspace/lib/routes-notes.js +47 -6
  14. package/workspace/package-lock.json +1 -1
  15. package/workspace/package.json +1 -1
  16. package/workspace/public/pm/components.css +697 -3
  17. package/workspace/public/pm/doc-editor.js +473 -0
  18. package/workspace/public/pm/doc-rail.js +161 -0
  19. package/workspace/public/pm/doc-remote.js +180 -0
  20. package/workspace/public/pm/dock-sessions.js +57 -13
  21. package/workspace/public/pm/frame-copy.js +2 -0
  22. package/workspace/public/pm/icons.js +1 -1
  23. package/workspace/public/pm/initiative-sheet.js +10 -2
  24. package/workspace/public/pm/initiatives-copy.js +1 -0
  25. package/workspace/public/pm/notes-links.js +110 -0
  26. package/workspace/public/pm/panel-tabs.js +85 -3
  27. package/workspace/public/pm/primitives.js +8 -4
  28. package/workspace/public/pm/state.js +3 -0
  29. package/workspace/public/pm/tab-doc.js +175 -170
  30. package/workspace/public/pm/today-agenda.js +6 -1
  31. package/workspace/public/pm/today-day.js +6 -0
  32. package/workspace/public/pm/view-notes.js +113 -6
  33. package/workspace/public/vendor/lucide-sprite.js +15 -2
  34. package/workspace/replaced-tests.json +43 -0
  35. package/workspace/server.js +30 -0
  36. package/workspace/test-lists/workspace-tabs-declutter.json +13 -0
  37. package/workspace/third-party/README.md +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.67.1",
3
+ "version": "0.69.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@7fc2d3a
1
+ kernel-source: pmos@b38c47a
2
2
  assembled: 2026-10-07
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-07T02:28:54Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T02:59:01Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-07T02:28:54Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T02:59:01Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -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-07T02:28:54Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T02:59:01Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -67,7 +67,7 @@ The palette reads the lists of all three scopes when it opens. When a list canno
67
67
 
68
68
  The palette opens as a sheet from the bottom edge. Each row is at least 48 pixels high, and the rows are reached by touch.
69
69
 
70
- On a phone, the scratchpad is the **compose line** at the bottom of a terminal tab. (On a desktop, open it with **▲ scratchpad** under the dock's tabs.) Type a message there and press **Send**. The text is typed into the session and the line clears. The line keeps your text for each session until you send it, so a message you did not finish is still there when you come back.
70
+ On a phone, the scratchpad is the **compose line** at the bottom of a terminal tab. (On a desktop, open it with **Scratchpad** in the bar at the bottom of the dock.) Type a message there and press **Send**. The text is typed into the session and the line clears. The line keeps your text for each session until you send it, so a message you did not finish is still there when you come back.
71
71
 
72
72
  ## Related
73
73
 
@@ -77,7 +77,7 @@ From the top:
77
77
  1. The horizon, the health reason when there is one, and the More button.
78
78
  2. Line 1: the stage and who the initiative waits on. Line 2: the stage steps, as a row of stages.
79
79
  3. One line of text: "Waiting on you: approval" with **Open gate**, or "Waiting on agent: build" with **Hand to agent**. When nothing is owed it says so.
80
- 4. Three tabs: **Breakdown**, **Files** and **History**. The Breakdown tab is the same Breakdown as in its own tab, and it shows here too.
80
+ 4. The tabs: **Breakdown**, **Files**, **History**, **Jira tickets**, **People** and **Notes**. The Breakdown tab is the same Breakdown as in its own tab, and it shows here too. **Notes** lists the meeting notes linked to the initiative, newest first, each with its summary and **Open**. See [Notes](notes.md).
81
81
 
82
82
  An initiative that is finished reads "Accepted". An initiative on hold, closed or cancelled reads that word instead of a stage. After 5 days with no activity, the line adds "idle 5 d".
83
83
 
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Guide: layout"
4
4
  description: "The frame: one top bar, the place on screen, the detail and the agent dock; the phone tabs and decks; the keys; and the older layout."
5
- timestamp: 2026-10-06
5
+ timestamp: 2026-10-07
6
6
  ---
7
7
 
8
8
  # Guide: layout
@@ -48,18 +48,45 @@ The dock holds your terminals, documents, conversations, skills and web pages. I
48
48
 
49
49
  - Press ⌘J (Ctrl-J), or the button on the rail, to open the dock. Press ⌘J again, or the fold button in the dock, to fold it to the rail.
50
50
  - The dock never opens by itself. A tab that opens while the dock is a rail waits in the dock until you open it.
51
- - The dock lists the sessions of the repository by initiative. A session is in the group of the initiative it is recorded on, or of its `initiative/<id>` branch. Then come **Not linked** (no initiative) and **Scheduled** (started by a schedule). Then **Open** lists the tabs that are not sessions, for example documents and skills.
52
- - Each session row shows the name, the type of work, and the branch when it is not main and not the initiative's own branch. Press the row to bring the session to the front.
51
+ - The dock lists the sessions of the repository by initiative. A session is in the group of the initiative it is recorded on, or of its `initiative/<id>` branch. Then come **Not linked** (no initiative) and **Scheduled** (started by a schedule). Then **Open** lists the tabs that are not sessions, for example documents and skills. Each one shows its title, what it is (for example PRD, Document or Web page) and its branch when it is not main. **✕** on an Open row closes the tab only. A terminal keeps running.
52
+ - Each session row starts with a mark for its state: ● working, ◼ quiet, ◆ needs you, ◇ maybe blocked, ○ ended. Under the name, the row shows the state when the session is not working, the type of work when one is set, and the branch when it is not main and not the initiative's own branch. Running sessions come first and ended sessions last. Press the row to bring the session to the front.
53
+ - Press the name of a group to fold it or open it. The number beside it counts its sessions. **Scheduled** is folded while none of its runs is running, because each run leaves an ended session. The group of the session in front always stays open, and the workspace keeps your choice for each group.
54
+ - **⋯** and **✕** show on the row in front, on the row under the pointer and on the row with the keyboard focus. On a touch screen they always show.
53
55
  - Press **⋯** on a row to change the name, the initiative or the type of work.
54
56
  - Press **✕** on a row to end the session. The row asks "End “name”?". Press **End** or **Cancel**. When you end the last running session on its own working copy, the message offers **Remove working copy**. This removes the folder only. The branch stays. If the folder has unsaved work, the workspace keeps it and tells you why.
55
57
  - **+ Session** at the top of the dock, or **+** on an initiative, opens one sheet. Type a name, or leave it empty to name the session after the skill and the initiative. **For** is the initiative. **On** is where the session runs. **Automatic** uses the initiative's own working copy on `initiative/<id>` (made the first time), or the main tree when there is no initiative. You can also choose a branch that has a working copy, or **New branch…**. Then press **Blank session** (the CLI and model from Settings) or a skill. The session starts at once.
56
58
  - The icons at the bottom of the dock show the sessions as a strip on top, give the dock the whole window (Focus), and fold the dock. On top, a select chooses the group, and each session of the group is a button. **+** starts a session, and **✕** ends the session in front.
57
59
  - 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.
58
60
  - **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 Focus again, or ⌘⇧J (Ctrl-Shift-J), to show the place again. Folding the dock also ends Focus.
59
- - **▲ 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.
61
+ - **Scratchpad** in the bar at the bottom of the dock opens and closes 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 and presses Enter. It is available only when a terminal is in front. **Copy** and **Clear** act on the note.
62
+ - The same bottom bar shows the title of the tab in front and a copy button that names what it copies: **Copy document** for a document, **Copy attach command** for a terminal.
63
+ - A document tab has one bar. The words at its start say whether the file is saved on disk or has unsaved changes. The copy button beside them shows the file's Confluence or Google Docs copy (see [Edit a document in place](#edit-a-document-in-place)). **Changes**, **Revert** and **Save** show when there are unsaved changes. **Markdown** shows the whole file as markdown in one box; press it again to go back. A file that you cannot edit has **Source** in its place. **⋯** holds the rest: the reading width (full width, or lines of 72 characters), **Normalize line breaks** (it joins hard-wrapped paragraphs into one line each in the buffer; read the result in Changes, then save) and **Hide the outline and connections**. **Run scripts** shows on the bar for an HTML page that can run its own scripts.
60
64
 
61
65
  Folding the dock, or going to another place, does not touch your terminals.
62
66
 
67
+ ## Edit a document in place
68
+
69
+ A markdown document is edited where you read it. The layout does not change when you edit.
70
+
71
+ - Press a paragraph, a heading, a list or a table to edit it. That block shows as markdown, with the cursor where you pressed. The other blocks stay as they are.
72
+ - **Esc** finishes the block. **Enter** at the end of a block starts a new block, and continues a list. **Backspace** at the start of a block joins it to the block above. The arrow keys move to the block above or below.
73
+ - **Ctrl** or **⌘** with **B**, **I**, **E** or **K** makes the selection bold, italic, code or a link. The same four buttons show above the block. **Ctrl** or **⌘** with **S** saves.
74
+ - Type `/` in an empty block to make it a heading, a list, a checklist, a quote or code.
75
+ - Press a checklist box to tick it.
76
+ - The frontmatter shows as **Properties**. Change a one-line value in its box. To change any other value, press **Markdown** and edit the file as text.
77
+ - **+ Add a block** at the end of the document adds a paragraph.
78
+
79
+ A block that you did not edit is saved exactly as it was. Nothing is saved until you press **Save**. **Changes** shows what will change, and **Revert** removes all unsaved changes.
80
+
81
+ The rail on the right has two tabs:
82
+
83
+ - **Outline** lists the headings. Press one to go to it.
84
+ - **Connections** shows the files that this document is tied to, as a graph and as a list. A tie is a link, a frontmatter path, the initiative, the anchor or a decision id. Nothing is guessed from words. Press a file to open it. The list also shows the document's copies, and the findings of the last consistency check that name the document.
85
+
86
+ To hide the rail, use **Hide the outline and connections** in the **⋯** menu.
87
+
88
+ The copy button on the bar shows the document's Confluence (or Google Docs) copy, from the same record as the [Docs page](docs.md). It says which side is newer. Press it to see both sides and the first difference. **Use PMOS version** writes the document to the copy, and **Use Confluence version** writes the copy to the document. Each waits 5 seconds, during which you can press **Undo**. **Compare again** reads every copy and changes nothing. A copy never changes by itself. If the document has no copy, the button says **Add a copy**: paste the address of a Confluence page or a Google Doc. Save your edits before you sync, because a sync uses the saved file.
89
+
63
90
  ## The detail
64
91
 
65
92
  The detail shows one item in full, for example an initiative or the profiles. It opens as a column between the place and the dock when you open an item. Press × at its top, or press Esc, to close it.
@@ -141,7 +168,7 @@ The `+` tile on the rail opens Add repository as a tab. The tab shows the scan o
141
168
 
142
169
  The list has a switch for Initiatives and Files. Choose Files to see the files of the current repository. Type in the filter to find a file. Press a file to open it in a tab.
143
170
 
144
- Under a terminal tab, the scratchpad holds a note that you can send to the terminal, copy or clear. The `+` session button opens a chooser for the new session. In a document tab, the Alignment button opens a drawer on the right. It shows the consistency findings for the document and lists only the documents that have a concrete tie to it.
171
+ Under a terminal tab, the scratchpad holds a note that you can send to the terminal, copy or clear. The `+` session button opens a chooser for the new session. In a document tab, the rail on the right shows the document's outline and its connections.
145
172
 
146
173
  The list has a switch for Work, Today and All repos, a filter, and the groups Needs you, In progress and Done. The badge on a row names the step where the initiative is, for example `review`, `accept` or `build`.
147
174
 
@@ -171,13 +198,13 @@ The third column holds your terminals, documents, conversations and guide pages.
171
198
 
172
199
  Press a file anywhere in the workspace to open it in a tab. Any text file opens, whatever its extension.
173
200
 
174
- - **Markdown** opens as a document. You can edit it and save it.
201
+ - **Markdown** opens as a document that you edit in place. See [Edit a document in place](#edit-a-document-in-place).
175
202
  - **JSON** opens pretty-printed. If the file is not valid JSON, the tab shows the text as written and says why.
176
203
  - **JSONL** opens as numbered records, one for each line. The tab marks a line that is not valid JSON.
177
- - **HTML** opens in `view` as the page it is. Use `raw` to see its source. The page cannot run scripts and cannot load anything from the network. To run the page's own scripts, press `run scripts`. This grant is for this tab only. The page can then run the scripts written inside it, but it still cannot load anything from the network, cannot load a script from another location, and cannot read the workspace. Press `scripts running — stop` to stop the scripts. Closing the tab also removes the grant.
204
+ - **HTML** opens as the page it is. Press **Source** to see its source. The page cannot run scripts and cannot load anything from the network. To run the page's own scripts, press `run scripts`. This grant is for this tab only. The page can then run the scripts written inside it, but it still cannot load anything from the network, cannot load a script from another location, and cannot read the workspace. Press `scripts running — stop` to stop the scripts. Closing the tab also removes the grant.
178
205
  - **Other text** opens as numbered source lines. Spreadsheets, PDFs and images open in their own views.
179
206
 
180
- A tab for a file that is not markdown is read-only. It has no edit, diff or save control, because the workspace saves only markdown.
207
+ A tab for a file that is not markdown is read-only. It has no Markdown, Changes or save control, because the workspace saves only markdown.
181
208
 
182
209
  If a file cannot be shown, the tab says why. The reasons are: the file is binary, it is larger than 2 MB, it is not valid text, or it is outside the workspace.
183
210
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: notes"
4
- description: "How to take notes in a meeting: listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, correct and tag lines, see what changed in your notes, replay the meeting, and get a summary."
4
+ description: "How to take notes in a meeting: listen to your microphone and the meeting on this machine, see decisions, actions and questions caught as they are said, check them against the repository, correct and tag lines, see what changed in your notes, replay the meeting, link a note to its meeting and its initiative, and get a summary."
5
5
  timestamp: 2026-10-06
6
6
  ---
7
7
 
@@ -86,6 +86,17 @@ When a meeting was heard with local transcription, its audio is kept on this mac
86
86
 
87
87
  To stop keeping audio, clear **Keep the audio for replay** in the settings. To delete one note's audio, press **Delete this note's audio** in the settings; the notes and the transcript stay. The browser's own recognition keeps no audio, so a meeting heard that way cannot be replayed.
88
88
 
89
+ ## Link a note to its meeting and its initiative
90
+
91
+ Under the title, a note shows two lines: **Meeting** and **Initiative**.
92
+
93
+ - **Meeting** is the calendar event the note was taken in, or **Not on the calendar** for a hallway chat or a call. Press **Change** to select an event of the note's day or the day before, or **Not on the calendar**.
94
+ - **Initiative** is the initiative the meeting was about. It says how it was set: **from the calendar event**, **suggested from what was said**, or **chosen by you**. Press **Change** to select a suggestion, any open initiative of the selected repository, or **None**. Press the initiative's name to open it.
95
+
96
+ When a note has no initiative, the summary suggests one: the open initiative that shares 3 or more words with what was said, when no other initiative shares as many. A suggestion never replaces an initiative you chose.
97
+
98
+ To take notes for a calendar event, open the event's detail on the Agenda and press **Take notes**. The note is linked to the event, and to the event's initiative when it has one. An event that already has a note shows its summary and **Open notes** instead. See [Today](today.md) and [the initiative sheet](initiatives-screen.md).
99
+
89
100
  ## Dictate into your notes
90
101
 
91
102
  1. Put the cursor where the text must go, then press **Dictate** and speak.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Today: what needs you"
4
- description: "The Agenda month and the calendar it remembers, the two-column day (your jobs and the agent's jobs), the eight kinds of card on Today, the one button on each card, the count, the empty state, and the pmos today command."
4
+ description: "The Agenda month and the calendar it remembers, the meeting notes of a meeting, the two-column day (your jobs and the agent's jobs), the eight kinds of card on Today, the one button on each card, the count, the empty state, and the pmos today command."
5
5
  timestamp: 2026-10-06
6
6
  ---
7
7
 
@@ -50,6 +50,8 @@ Each day of the month answers three questions:
50
50
 
51
51
  The switch above the month shows one answer at a time: **Overview** (all three), **Full** (the time in meetings), **Key** (the meeting that matters), **Prep** (how ready you are) or **Free** (the free blocks of 90 minutes or more).
52
52
 
53
+ A meeting's detail ends with **Meeting notes**. Press **Take notes** to start a note linked to the meeting, and to its initiative when the brief names one. When the meeting has a note, the detail shows its summary and **Open notes**, and the meeting's row shows **notes**. See [Notes](notes.md).
54
+
53
55
  The memory of a meeting says what kind of meeting it is (one-to-one, onboarding or group), how many times it was held, how many are to come, and why it matters.
54
56
 
55
57
  To mark a meeting yourself, open it. Press **Mark as key** to make this time a key meeting, or **Key every time** for every time it happens. On a meeting that is key already, press **Not key** or **Never key**. Your mark is used before what the calendar says. Press **Remove my mark** to go back to what the calendar says. The marks are kept in `calendar-marks.json` in the state folder. **Before this meeting** lists the prep. **Last time** is folded: open it to read the notes and what stayed open.
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+ /* notes-links.js — which initiative a note belongs to, suggested locally (meeting-notes Run 4). Local only.
3
+ *
4
+ * initiativesOf(root) → [{ id, title, anchor, intent }] the open initiatives of one repository
5
+ * suggest(note, open) → { pick: { id, title, shared } | null, ranked: [{ id, title, shared }] }
6
+ *
7
+ * HOW IT SUGGESTS. It compares the words of the note (title, body, transcript lines, and the calendar event's title) with the
8
+ * words of each open initiative (id, title and the first paragraph of its record). Short and common words do not count. The
9
+ * initiative that shares the most distinct words is the suggestion when it shares 3 or more and no other initiative shares
10
+ * as many. Otherwise there is no suggestion, and the PM chooses. A suggestion never replaces the PM's own choice
11
+ * (lib/notes.js link refuses `by: 'local'` over `by: 'pm'`).
12
+ *
13
+ * WHAT IT NEVER DOES. It sends nothing and opens no network module. It reads the repository's planning/initiatives records
14
+ * through lib/live-notes.js loadInitiatives and writes nothing.
15
+ */
16
+ const liveNotes = require('./live-notes.js');
17
+
18
+ const MIN_SHARED = 3;
19
+ const MAX_RANKED = 50;
20
+ const STOP = new Set(('the and for are but not you all any can had her was one our out has his how its may new now old see two way who '
21
+ + 'did get got let put say she too use with this that from they will would there their what when which while about into than then them '
22
+ + 'these those have been were being also just like some more most much very only over such each other could should after before where '
23
+ + 'because does doing done make made need needs want wants yes okay meeting notes note today tomorrow yang dan untuk dengan ini itu '
24
+ + 'dari ada tidak akan bisa kita kami saya juga sudah belum').split(' '));
25
+
26
+ /** Distinct content words, lower case, stemmed only by a trailing s. */
27
+ function terms(text) {
28
+ const out = new Set();
29
+ for (const w of String(text || '').toLowerCase().match(/[\p{L}\p{N}]+/gu) || []) {
30
+ if (w.length < 3 || STOP.has(w) || /^\d+$/.test(w)) continue;
31
+ out.add(w.length > 4 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w);
32
+ }
33
+ return out;
34
+ }
35
+ function noteText(note) {
36
+ const n = note || {};
37
+ return [n.title, n.body, n.meeting && n.meeting.title, ...(n.transcript || []).map((s) => s && s.text)].join('\n');
38
+ }
39
+ function initiativesOf(root) {
40
+ try { return liveNotes.loadInitiatives(root).open.map((i) => ({ id: i.id, title: i.title, anchor: i.anchor, intent: i.intent })); } catch { return []; }
41
+ }
42
+ /** Pure. `open` is initiativesOf's list. */
43
+ function suggest(note, open) {
44
+ const mine = terms(noteText(note));
45
+ const ranked = (open || []).map((i) => {
46
+ const theirs = terms([String(i.id).replace(/[-_.]/g, ' '), i.title, i.intent].join(' '));
47
+ let shared = 0;
48
+ for (const t of theirs) if (mine.has(t)) shared += 1;
49
+ return { id: i.id, title: i.title, shared };
50
+ }).sort((a, b) => b.shared - a.shared || String(a.title).localeCompare(String(b.title)));
51
+ const top = ranked[0];
52
+ const pick = top && top.shared >= MIN_SHARED && !(ranked[1] && ranked[1].shared === top.shared) ? top : null;
53
+ return { pick, ranked: ranked.slice(0, MAX_RANKED) };
54
+ }
55
+
56
+ module.exports = { terms, noteText, initiativesOf, suggest, MIN_SHARED };
@@ -83,7 +83,84 @@ const wordsOf = (s) => (String(s || '').match(/[\p{L}\p{N}']+/gu) || []).length;
83
83
  /** The row a list shows. Never the body or the transcript. */
84
84
  function rowOf(n) {
85
85
  return { id: n.id, title: n.title || 'Untitled note', created_at: n.created_at, updated_at: n.updated_at, private: !!n.private,
86
- words: wordsOf(n.body), segments: (n.transcript || []).length, summary: !!(n.summary || (n.agent && n.agent.markdown)) };
86
+ words: wordsOf(n.body), segments: (n.transcript || []).length, summary: !!(n.summary || (n.agent && n.agent.markdown)),
87
+ meeting: n.meeting || null, initiative: n.initiative || null, ws: n.ws || null,
88
+ // a private note shows its title on the calendar and the initiative, never its summary
89
+ summary_lines: n.private ? [] : summaryLines(n) };
90
+ }
91
+ /** Up to three lines of the note's summary: the agent's, else the local overview. Plain text. */
92
+ function summaryLines(n) {
93
+ if (n.agent && n.agent.markdown) {
94
+ return String(n.agent.markdown).split(/\r?\n/).map((l) => l.replace(/^\s*(?:#+|[-*]|\d+[.)])\s*/, '').replace(/\*\*|__|`/g, '').trim())
95
+ .filter((l) => l && !/^summary$/i.test(l)).slice(0, 3).map((l) => l.slice(0, 200));
96
+ }
97
+ const ov = n.summary && n.summary.sections && n.summary.sections.overview;
98
+ return Array.isArray(ov) ? ov.slice(0, 3).map((x) => String((x && x.text) || '').slice(0, 200)).filter(Boolean) : [];
99
+ }
100
+
101
+ /* ── the meeting and the initiative a note belongs to (meeting-notes Run 4) ──────────────────────── */
102
+ const EVENT_ID_RE = /^[A-Za-z0-9_-]{1,40}$/;
103
+ const INI_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
104
+ const minuteOf = (s) => { const t = Date.parse(s); return Number.isFinite(t) ? Math.floor(t / 60000) : null; };
105
+ const normTitle = (t) => String(t || '').toLowerCase().replace(/\s+/g, ' ').trim();
106
+ /** A meeting link: a calendar event (its id, where it came from, its start, end and title) or a meeting that is not on the
107
+ * calendar. null unlinks. Returns { ok, meeting } or an error. */
108
+ function cleanMeeting(m, now) {
109
+ if (m === null) return { ok: true, meeting: null };
110
+ if (!m || typeof m !== 'object') return err(400, 'say which meeting: { kind: "event", … } or { kind: "adhoc" }');
111
+ if (m.kind === 'adhoc') return { ok: true, meeting: { kind: 'adhoc', linked_at: iso(now) } };
112
+ if (m.kind !== 'event') return err(400, 'the meeting kind must be event or adhoc');
113
+ const title = cleanTitle(m.title);
114
+ if (!title) return err(400, 'a calendar event needs its title');
115
+ if (minuteOf(m.start) === null) return err(400, 'a calendar event needs its start time');
116
+ if (m.end != null && minuteOf(m.end) === null) return err(400, 'the end of the event is not a time');
117
+ if (m.event_id != null && !EVENT_ID_RE.test(String(m.event_id))) return err(400, 'that is not a calendar event id');
118
+ return { ok: true, meeting: { kind: 'event', event_id: m.event_id != null ? String(m.event_id) : null, src: m.src === 'memory' ? 'memory' : 'brief',
119
+ start: new Date(Date.parse(m.start)).toISOString(), end: m.end != null ? new Date(Date.parse(m.end)).toISOString() : null, title, linked_at: iso(now) } };
120
+ }
121
+ /** An initiative link: { id, ws, repo, title, by }. `repo` is the repository's name in the page, to open the initiative. `by` is pm (chosen), event (from the calendar event) or local (suggested). */
122
+ function cleanInitiative(x, by) {
123
+ if (x === null) return { ok: true, initiative: null };
124
+ if (!x || typeof x !== 'object' || !INI_ID_RE.test(String(x.id || ''))) return err(400, 'that is not an initiative id');
125
+ return { ok: true, initiative: { id: String(x.id), ws: typeof x.ws === 'string' && x.ws.length <= 40 ? x.ws : null,
126
+ repo: typeof x.repo === 'string' && x.repo.length <= 120 ? x.repo : null, title: x.title ? cleanTitle(x.title) : null, by: ['pm', 'event', 'local'].includes(by) ? by : 'pm', ...(by === 'local' && Number.isFinite(x.shared) ? { shared: x.shared } : {}) } };
127
+ }
128
+ /** Whether a note's meeting is this calendar event: the same id, or the same start minute and title (the brief and the
129
+ * calendar memory give one meeting different ids). Pure. */
130
+ function sameEvent(meeting, e) {
131
+ if (!meeting || meeting.kind !== 'event' || !e) return false;
132
+ if (meeting.event_id && e.event_id && meeting.event_id === e.event_id) return true;
133
+ const a = minuteOf(meeting.start); const b = minuteOf(e.start);
134
+ return a !== null && a === b && normTitle(meeting.title) === normTitle(e.title);
135
+ }
136
+ /** The note already linked to this calendar event, or null. */
137
+ function findByEvent(dir, e) {
138
+ let names = [];
139
+ try { names = fs.readdirSync(notesDir(dir)).filter((f) => f.endsWith('.json')); } catch { return null; }
140
+ const hits = names.map((f) => load(dir, f.slice(0, -5))).filter((n) => n && sameEvent(n.meeting, e));
141
+ hits.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)));
142
+ return hits[0] || null;
143
+ }
144
+ /** Link a note to its meeting and to its initiative. A field left out is kept. The PM's initiative is never replaced by a
145
+ * suggestion: `by: 'local'` is refused when the PM chose one. */
146
+ function link(dir, { id, meeting, initiative, by = 'pm', now = Date.now() } = {}) {
147
+ const note = load(dir, id);
148
+ if (!note) return err(404, 'no such note');
149
+ if (meeting !== undefined) {
150
+ const m = cleanMeeting(meeting, now);
151
+ if (!m.ok) return m;
152
+ note.meeting = m.meeting;
153
+ if (m.meeting && m.meeting.kind === 'event' && !String(note.title || '').trim()) note.title = m.meeting.title;
154
+ }
155
+ if (initiative !== undefined) {
156
+ if (by !== 'pm' && note.initiative && note.initiative.by === 'pm') return { ok: true, note, kept: true };
157
+ const c = cleanInitiative(initiative, by);
158
+ if (!c.ok) return c;
159
+ note.initiative = c.initiative;
160
+ }
161
+ note.updated_at = iso(now);
162
+ store(dir, note);
163
+ return { ok: true, note };
87
164
  }
88
165
  function list(dir) {
89
166
  let names = [];
@@ -93,10 +170,15 @@ function list(dir) {
93
170
  notes.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)) || (a.id < b.id ? 1 : -1));
94
171
  return { ok: true, notes };
95
172
  }
96
- function create(dir, { title = '', now = Date.now() } = {}) {
173
+ function create(dir, { title = '', meeting, initiative, now = Date.now() } = {}) {
97
174
  const at = iso(now);
98
- const note = { v: 1, id: newId(now), title: cleanTitle(title), created_at: at, updated_at: at, body: '', private: false,
99
- recorded_from: null, transcript: [], summary: null, agent: null };
175
+ const m = meeting === undefined ? { ok: true, meeting: null } : cleanMeeting(meeting, now);
176
+ if (!m.ok) return m;
177
+ // a note taken from a calendar event inherits the event's initiative, marked as coming from the event
178
+ const c = initiative === undefined || initiative === null ? { ok: true, initiative: null } : cleanInitiative(initiative, m.meeting && m.meeting.kind === 'event' ? 'event' : 'pm');
179
+ if (!c.ok) return c;
180
+ const note = { v: 1, id: newId(now), title: cleanTitle(title) || (m.meeting && m.meeting.title) || '', created_at: at, updated_at: at, body: '', private: false,
181
+ recorded_from: null, transcript: [], summary: null, agent: null, meeting: m.meeting, initiative: c.initiative };
100
182
  store(dir, note);
101
183
  return { ok: true, note };
102
184
  }
@@ -600,5 +682,5 @@ function importAgent(dir, note) {
600
682
  return true;
601
683
  }
602
684
 
603
- module.exports = { list, read, create, save, remove, record, appendTranscript, updateSegment, setLabel, editLine, versionsOf, renameSpeaker, speakerOf, categoryOf, isFact, CATS, PM_CATS, VERSION_FOLD_MS, setPrivate, dictation, cleanSentences, spokenPunctuation,
685
+ module.exports = { list, read, create, link, findByEvent, sameEvent, cleanMeeting, summaryLines, save, remove, record, appendTranscript, updateSegment, setLabel, editLine, versionsOf, renameSpeaker, speakerOf, categoryOf, isFact, CATS, PM_CATS, VERSION_FOLD_MS, setPrivate, dictation, cleanSentences, spokenPunctuation,
604
686
  summarize, localSummary, agentHandoff, agentStarted, toMarkdown, promptFor, fmtTime, ownerOf, dueOf, ID_RE, notesDir, handoffOf, agentOutOf };
@@ -1,9 +1,12 @@
1
1
  'use strict';
2
- /* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1 to 3; lib/route-modules.js).
2
+ /* routes-notes.js — the Notes view's routes (meeting-notes, Runs 1 to 4; lib/route-modules.js).
3
3
  *
4
4
  * GET /api/notes → { ok, notes: [row] } newest first; a row never carries the body
5
5
  * GET /api/notes/one?id=… → { ok, note } imports an agent summary written since the request
6
- * POST /api/notes/new { title? } → { ok, note }
6
+ * POST /api/notes/new { title?, meeting?, initiative? } → { ok, note, reused } a calendar event that already has a note
7
+ * gets that note back (reused: true), so one meeting keeps one note (Run 4)
8
+ * POST /api/notes/link { id, meeting?, initiative?, from_event? } → { ok, note, kept? } the PM's link; null unlinks (Run 4)
9
+ * GET /api/notes/initiatives?id=…&ws=… → { ok, open: [{ id, title }], pick } the repository's open initiatives, and the local suggestion
7
10
  * POST /api/notes/save { id, title?, body? } → { ok, updated_at }
8
11
  * POST /api/notes/dictate { text } → { ok, ops } one dictated phrase as insert operations (lib/notes.js dictation)
9
12
  * POST /api/notes/record { id, ws? } → { ok, recorded_from } `ws` is the repository the note's lines are checked against
@@ -19,7 +22,8 @@
19
22
  * { language?, model?, keep_audio? } → { ok, whisper } the language, the model and keeping audio, for this machine
20
23
  * POST /api/notes/whisper-setup { ws? } → { ok, started, session, command } a terminal with the setup typed in, not run
21
24
  * POST /api/notes/private { id, private } → { ok, private }
22
- * POST /api/notes/summarize { id } → { ok, summary } the local summary
25
+ * POST /api/notes/summarize { id, ws? } → { ok, summary, initiative } the local summary; with ws and no initiative yet,
26
+ * the local suggestion is linked (by: 'local')
23
27
  * POST /api/notes/summarize-agent { id, ws? } → { ok, started, session, handoff, out, withheld, reason }
24
28
  * POST /api/notes/delete { id } → { ok }
25
29
  *
@@ -38,6 +42,7 @@ const notes = require('./notes.js');
38
42
  const whisper = require('./notes-whisper.js');
39
43
  const notesRepo = require('./notes-repo.js');
40
44
  const notesAudio = require('./notes-audio.js');
45
+ const notesLinks = require('./notes-links.js');
41
46
  const jevNotes = require('./jev-notes.js');
42
47
  const tsSettings = require('./typesafe-settings.js');
43
48
 
@@ -47,7 +52,7 @@ const CHECKED = new Set(['decision', 'action', 'statement']);
47
52
  const prefixes = ['/api/notes'];
48
53
  const POSTS = new Set(['/api/notes/new', '/api/notes/save', '/api/notes/dictate', '/api/notes/record', '/api/notes/transcript',
49
54
  '/api/notes/private', '/api/notes/summarize', '/api/notes/summarize-agent', '/api/notes/delete', '/api/notes/audio', '/api/notes/label',
50
- '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/line', '/api/notes/recording-delete']);
55
+ '/api/notes/speaker', '/api/notes/switch', '/api/notes/whisper-setup', '/api/notes/line', '/api/notes/recording-delete', '/api/notes/link']);
51
56
 
52
57
  /* ── labels and checks, one note at a time ───────────────────────────────────────────────────── */
53
58
  const chains = new Map(); // note id → the promise of its enrichment so far
@@ -144,6 +149,19 @@ async function handle(req, res, url, ctx) {
144
149
  notesAudio.serve(req, res, dir, nid);
145
150
  return true;
146
151
  }
152
+ if (p === '/api/notes/initiatives') {
153
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
154
+ const wsId = url.searchParams.get('ws');
155
+ const w = wsId ? ctx.getWs(wsId) : null;
156
+ if (!w || w.id !== wsId) { ctx.json(res, { ok: false, error: 'This repository is not registered.' }, 404); return true; }
157
+ const open = notesLinks.initiativesOf(w.root);
158
+ const nid = url.searchParams.get('id');
159
+ const r = nid ? notes.read(dir, nid) : null;
160
+ if (r && !r.ok) { send(ctx, res, r); return true; }
161
+ const s = r ? notesLinks.suggest(r.note, open) : { pick: null, ranked: [] };
162
+ ctx.json(res, { ok: true, open: open.map((i) => ({ id: i.id, title: i.title })), pick: s.pick });
163
+ return true;
164
+ }
147
165
  if (p === '/api/notes/engine') {
148
166
  if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
149
167
  const wsId = url.searchParams.get('ws');
@@ -160,7 +178,19 @@ async function handle(req, res, url, ctx) {
160
178
  if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
161
179
  const id = body && typeof body.id === 'string' ? body.id : '';
162
180
  const done = (r) => { send(ctx, res, r); return true; };
163
- if (p === '/api/notes/new') return done(notes.create(dir, { title: body.title }));
181
+ if (p === '/api/notes/new') {
182
+ // one calendar event keeps one note: Take notes on an event that already has one opens it
183
+ const m = body.meeting;
184
+ if (m && m.kind === 'event') {
185
+ const hit = notes.findByEvent(dir, m);
186
+ if (hit) return done({ ok: true, note: hit, reused: true });
187
+ }
188
+ const r = notes.create(dir, { title: body.title, meeting: m, initiative: body.initiative });
189
+ if (r.ok) r.reused = false;
190
+ return done(r);
191
+ }
192
+ // from_event: the initiative is the calendar event's own, and never replaces the PM's choice
193
+ if (p === '/api/notes/link') return done(notes.link(dir, { id, meeting: body.meeting, initiative: body.initiative, by: body.from_event === true ? 'event' : 'pm' }));
164
194
  if (p === '/api/notes/dictate') return done(notes.dictation(body.text));
165
195
  if (p === '/api/notes/save') return done(notes.save(dir, { id, title: body.title, body: body.body }));
166
196
  const wsOf = () => {
@@ -238,7 +268,18 @@ async function handle(req, res, url, ctx) {
238
268
  if (r.ok && r.private) { if (transcriber) transcriber.drop(id); notesAudio.remove(dir, id); }
239
269
  return done(r);
240
270
  }
241
- if (p === '/api/notes/summarize') return done(notes.localSummary(dir, { id }));
271
+ if (p === '/api/notes/summarize') {
272
+ const r = notes.localSummary(dir, { id });
273
+ if (!r.ok) return done(r);
274
+ const ws = wsOf();
275
+ const cur = notes.read(dir, id);
276
+ if (ws.ok && ws.w && cur.ok && !cur.note.initiative) {
277
+ const s = notesLinks.suggest(cur.note, notesLinks.initiativesOf(ws.w.root));
278
+ if (s.pick) { const l = notes.link(dir, { id, initiative: { id: s.pick.id, ws: ws.w.id, title: s.pick.title, shared: s.pick.shared }, by: 'local' }); if (l.ok) r.initiative = l.note.initiative; }
279
+ }
280
+ if (r.initiative === undefined) r.initiative = cur.ok ? cur.note.initiative || null : null;
281
+ return done(r);
282
+ }
242
283
  if (p === '/api/notes/delete') { if (transcriber) transcriber.drop(id); const r = notes.remove(dir, id); if (r.ok) notesAudio.remove(dir, id); return done(r); }
243
284
  // summarize-agent: the handoff file first (refused for a private note), then the PM's own agent session
244
285
  let w = null;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.67.1",
3
+ "version": "0.69.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.67.1",
3
+ "version": "0.69.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",