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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/command-palette.md +1 -1
- package/workspace/guide/initiatives-screen.md +1 -1
- package/workspace/guide/layout.md +35 -8
- package/workspace/guide/notes.md +12 -1
- package/workspace/guide/today.md +3 -1
- package/workspace/lib/notes-links.js +56 -0
- package/workspace/lib/notes.js +87 -5
- package/workspace/lib/routes-notes.js +47 -6
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +697 -3
- package/workspace/public/pm/doc-editor.js +473 -0
- package/workspace/public/pm/doc-rail.js +161 -0
- package/workspace/public/pm/doc-remote.js +180 -0
- package/workspace/public/pm/dock-sessions.js +57 -13
- package/workspace/public/pm/frame-copy.js +2 -0
- package/workspace/public/pm/icons.js +1 -1
- package/workspace/public/pm/initiative-sheet.js +10 -2
- package/workspace/public/pm/initiatives-copy.js +1 -0
- package/workspace/public/pm/notes-links.js +110 -0
- package/workspace/public/pm/panel-tabs.js +85 -3
- package/workspace/public/pm/primitives.js +8 -4
- package/workspace/public/pm/state.js +3 -0
- package/workspace/public/pm/tab-doc.js +175 -170
- package/workspace/public/pm/today-agenda.js +6 -1
- package/workspace/public/pm/today-day.js +6 -0
- package/workspace/public/pm/view-notes.js +113 -6
- package/workspace/public/vendor/lucide-sprite.js +15 -2
- package/workspace/replaced-tests.json +43 -0
- package/workspace/server.js +30 -0
- package/workspace/test-lists/workspace-tabs-declutter.json +13 -0
- 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.
|
|
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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
|
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.
|
|
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-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/workspace/guide/notes.md
CHANGED
|
@@ -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.
|
package/workspace/guide/today.md
CHANGED
|
@@ -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 };
|
package/workspace/lib/notes.js
CHANGED
|
@@ -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
|
|
99
|
-
|
|
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
|
|
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? }
|
|
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 }
|
|
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')
|
|
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')
|
|
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;
|
package/workspace/package.json
CHANGED