create-pmos 0.116.0 → 0.118.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/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/notes.md +43 -3
  7. package/workspace/guide/today.md +22 -13
  8. package/workspace/lib/brief.js +2 -2
  9. package/workspace/lib/day-marks.js +29 -9
  10. package/workspace/lib/goals.js +68 -10
  11. package/workspace/lib/notes.js +139 -17
  12. package/workspace/lib/routes-notes.js +68 -11
  13. package/workspace/package-lock.json +2 -2
  14. package/workspace/package.json +1 -1
  15. package/workspace/public/pm/components.css +289 -91
  16. package/workspace/public/pm/dest-notes.js +136 -29
  17. package/workspace/public/pm/note-dock.js +204 -164
  18. package/workspace/public/pm/notes-context.js +60 -0
  19. package/workspace/public/pm/notes-links.js +32 -6
  20. package/workspace/public/pm/primitives.js +1 -1
  21. package/workspace/public/pm/today-agenda.js +27 -53
  22. package/workspace/public/pm/today-copy.js +34 -15
  23. package/workspace/public/pm/today-day.js +98 -76
  24. package/workspace/public/pm/today-goals.js +159 -15
  25. package/workspace/public/pm/today-jobs.js +84 -0
  26. package/workspace/public/pm/today-mentions.js +50 -0
  27. package/workspace/public/pm/topbar.js +71 -32
  28. package/workspace/public/pm/view-notes.js +142 -73
  29. package/workspace/server.js +10 -8
  30. package/workspace/test-files.json +4 -0
  31. package/workspace/test-lists/today-goals-jobs-ui.json +5 -0
  32. package/workspace/test-lists/today-goals-jobs.json +5 -0
  33. package/workspace/test-lists/today-mentions-cards.json +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.116.0",
3
+ "version": "0.118.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@affb04c
1
+ kernel-source: pmos@5737e5d
2
2
  assembled: 2026-10-08
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-08T06:17:49Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T07:37:35Z"}
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-08T06:17:49Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T07:37:35Z"}
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-08T06:17:49Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-08T07:37:35Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -7,7 +7,47 @@ timestamp: 2026-10-08
7
7
 
8
8
  # Guide: notes
9
9
 
10
- **Notes** is a page in the top bar, between Docs and People. The list on the left shows every note by initiative, with the time or day and an amber dot when a note conflicts with a file. The note you pick opens on the right: its title, the **Resume** button and the **⋯** menu, then the conflicts, the highlights, the overview, the decisions, the next steps (each with **Make task**), the open questions and a **Discussion** fold with the topics and the transcript. Press the title, the overview or any decision, next step or question to edit it. PMOS keeps the words the agent wrote. At the top right of the bar, **Record** starts a new note and listens at once. While it listens, the bar shows the red dot and the clock, a **Highlight** button and **Pause**. When it is paused, the bar shows **Resume**. Press the clock to open the note as a pop-up. Each note below is shown in the pop-up and on the page in the same way.
10
+ **Notes** is a page in the top bar, between Docs and People. The list on the left shows every note by initiative, with the time or day and an amber dot when a note conflicts with a file. The note you pick opens on the right. That column is the full note: there is no second, pop-up version of it. It has the title with its icons, the meeting context, the conflicts, the highlights, the overview, the decisions, the next steps (each with a **Make task** icon), the open questions and a **Discussion** fold with the topics and the transcript. Press the title, the overview or any decision, next step or question to edit it. PMOS keeps the words the agent wrote.
11
+
12
+ Every button in the top bar and in the note is an icon. Hold the pointer on an icon to read its name.
13
+
14
+ ## The Record control
15
+
16
+ The Record control is at the right of the top bar. It has two groups, always in the same place.
17
+
18
+ - **The meeting group** shows only while a meeting is recording or paused. It starts with the meeting's name and its clock (paused: **paused · 48 min left**). Press the name to open the note. While recording, the group has a highlighter icon, **Pause** and **Stop**. While paused, it has **Resume** and **Stop**. When there is nothing to resume, the group is not there.
19
+ - **The new group** is the red microphone with a plus, and an arrow. The microphone starts an untitled note and listens at once. The arrow lists the meetings of today that have not started and have no note, with the next one first. Each shows who is coming and how many agenda items it has. Press one to start a note for that meeting, with its context. Both are unavailable while a meeting is recording. Pause or stop it first.
20
+
21
+ Starting a note, from the Record control or from **Take notes** on a calendar event, shows the Notes page with that note.
22
+
23
+ ## Pause, resume and stop
24
+
25
+ **Pause** keeps the meeting open. PMOS stops listening and the clock stops. It does not ask, and it does not write the final notes yet. You can resume for one hour. The top bar shows the minutes left. After an hour the meeting ends by itself, and PMOS writes its final notes.
26
+
27
+ **Resume** continues the most recent paused meeting, in the same note, with the clock where it stopped. It is not available for a meeting that was stopped, for one that ran out of its hour, or for an older meeting after a later one was recorded. Its tooltip says why.
28
+
29
+ **Stop** ends the meeting for good. PMOS asks first: **Cancel**, **Pause instead** (while recording) or **Stop for good**. A stopped meeting cannot be resumed. PMOS writes the final notes at once.
30
+
31
+ If you start a note by mistake and stop it within a minute, or before anything was heard, and you typed nothing, PMOS deletes the note. The meeting you paused before it can be resumed again.
32
+
33
+ ## While the meeting is held
34
+
35
+ While a meeting is recording or paused, the note shows a live block under the context: the status, **Your notes** (type a line and press Enter) and the live transcript. The block stays steady while it listens. New lines are added at the end. Your place, the line you are typing and its cursor do not move. If the transcript is scrolled to its end, it follows the new lines. The note shows **Stop** while recording, and **Resume** and **Stop** while paused. **Highlight** and **Pause** are in the top bar only.
36
+
37
+ ## The meeting context
38
+
39
+ A note made for a calendar meeting keeps what PMOS knew of the meeting when the note was made, so the note reads clearly later. The **Meeting context** card shows it. It is open while the meeting is held and folds to one line after. The card shows only what the calendar and the morning brief carry:
40
+
41
+ - **People**: the names in the event's "with" line, only while calendar names are on (Settings, People). Otherwise the card shows the number of attendees.
42
+ - **Agenda**: the steps the brief planned for the meeting. An item shows a tick and a time when a transcript line shares two of its words. That is a word match on this machine, so the card says **heard**, never done.
43
+ - **What it is for**: the brief's purpose and how to be ready, the initiative and the related items.
44
+ - **Details**: the time and date, the series, your role, whether someone from outside the company attends, and the brief's links.
45
+
46
+ The calendar memory holds no invitation text, and the morning brief never copies it. So there is no description or location unless the brief gives one. PMOS never makes up a field. A note with no meeting says so. Press the link icon, or **Link to a meeting** in the **⋯** menu, to pick one. When the note keeps its meeting's people, naming a speaker starts with them. The copy icon puts the note on the clipboard as Markdown, with the context first.
47
+
48
+ ## Exact times and dates
49
+
50
+ The clock icon beside the note's time shows the exact times and dates: the calendar event, each stretch that was recorded with its date, time and time zone, the total recorded, the status, and the time until which the meeting can be resumed. Hold the pointer on a note in the list to see when it started.
11
51
 
12
52
  The Notes view shows one meeting, top to bottom. A bar at the top shows whether notes are being taken, with **Stop** or **Listen** and the **⋯** menu. Under it are the title, the day, the meeting, the initiative and the people. Then come the overview, the key points, the decisions, the next steps, the open questions and your own notes, each item with the meeting time it was said or written. The transcript folds away at the end. Notes are kept on this machine, outside every repository.
13
53
 
@@ -150,7 +190,7 @@ To stop keeping audio, clear **Keep the audio for replay** in the settings. To d
150
190
 
151
191
  ## Capture highlights during the meeting
152
192
 
153
- While listening, press the highlighter in the top bar (or in the note) to capture the last 30 seconds of conversation. A pop-up shows the highlight's progress:
193
+ While listening, press the highlighter in the top bar to capture the last 30 seconds of conversation. A pop-up opens just below the Record control and shows the highlight's progress. Its icons keep the highlight, discard it, or close the pop-up (the highlight keeps working):
154
194
 
155
195
  1. **Capturing**: the last 30 seconds are being recorded.
156
196
  2. **Listening**: the highlight listens forward. Choose how long: **30 seconds**, **1 minute** or **2 minutes**. The remaining time counts down.
@@ -190,7 +230,7 @@ Under the title, a note shows two lines: **Meeting** and **Initiative**.
190
230
 
191
231
  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.
192
232
 
193
- To take notes for a calendar event, open the event's detail on the Agenda and press **Take notes**. PMOS starts to listen at once, to your microphone and to the meeting when local transcription is set up. Pick the meeting's tab or window when the browser asks. Let people know that you are taking notes. The note is linked to the event, and to the event's initiative when it has one. The top bar shows the red dot and the clock on every page. Press the clock to open the note as a pop-up: add your notes, read the key points, and press **Stop** when the meeting ends. The pop-up stays steady while it listens: new lines are added at the end, and your place in the pop-up, the line you are typing and its cursor do not move. If the transcript is scrolled to its end, it follows the new lines. Press **Minimize** or Esc to make it small again. **Open in Notes** in the **⋯** menu shows it on the full screen. One note is taken at a time. If you press **Take notes** on another event while a note listens, PMOS asks first: **Keep it**, or **Stop it and start this one**. The note stays in the top bar after a reload. Press **Stop** in the note to put it away. An event that already has a note shows its summary and **Open notes**, which opens the note in the pop-up. See [Today](today.md) and [the initiative sheet](initiatives-screen.md).
233
+ To take notes for a calendar event, open the event's detail on the Agenda and press **Take notes**. PMOS starts to listen at once, to your microphone and to the meeting when local transcription is set up. Pick the meeting's tab or window when the browser asks. Let people know that you are taking notes. The note is linked to the event, and to the event's initiative when it has one. It keeps the event's context. The Notes page shows the note, and the top bar shows the meeting's name and clock on every page. One note is taken at a time: pause or stop the one that listens before you start another. An event that already has a note shows its summary and **Open notes**, which shows the note in Notes. See [Today](today.md) and [the initiative sheet](initiatives-screen.md).
194
234
 
195
235
  ## Dictate into your notes
196
236
 
@@ -16,7 +16,7 @@ Above the cards, Today shows the day in two columns.
16
16
 
17
17
  Press **Prepare my day** to start the day. It runs from the brief to the delivery of each agent job, and you do not press anything else until a draft is ready for you. The page [Guide: Today and the proactive engine](proactive-engine.md) describes each step.
18
18
 
19
- The bar at the top of the day is one line. It shows the greeting, the date and how many goals are unfinished. While **Prepare my day** runs, it also shows the four steps (Read, Sort, Agents work, You approve) as a small progress bar; press a step to see its detail. When nothing runs, it says when the day was last prepared. **Sources** and **Check now** are icon buttons in the bar.
19
+ The bar at the top of the day is one line. It shows the greeting, the date and **Prepare my day** (**Prepare again** when the day is prepared). While **Prepare my day** runs, it also shows the four steps (Read, Sort, Agents work, You approve) as a small progress bar; press a step to see its detail. The **⋯** menu holds the rest: when the day was last prepared, how many agent jobs are in each state, the next daytime check, **Check now**, **Settings** (the sources) and, when Today shows one repository, **Show items with no repository**.
20
20
 
21
21
  If the morning-brief session stops before it submits a brief, the Read step says so and gives the reason. **Prepare my day** is then ready again, so press it to try once more. If the session says that its folder is not there, open the repository again from its current folder first.
22
22
 
@@ -32,29 +32,22 @@ An agent job can stop with its session still open, for example when the agent as
32
32
 
33
33
  Each job session is named "Job ·" and the first words of the job, so the session list stays short.
34
34
 
35
- The left column shows one section at a time. A row of tabs at the top chooses it: **Agenda**, **Goals**, **Jobs**, **Mentions** and **Result**. Agenda is the first one shown. The column remembers your last choice in this browser. When you select a job, a goal or a mention, the column goes to its tab. The Agenda tab holds the month. **Result** counts the jobs and lists each finished one with its draft summary or the reason it failed. The **Goals** tab holds your goals for today. The day is open when you go to Agenda, also before you press **Prepare my day**. The other things that wait for you, such as outcome checks, report drafts and sessions, are jobs too. Each one is a row of **Needs you** in the jobs. Press it to see its card, with its own buttons (for example **Draft now**), in the detail column. When the day is folded, they show as cards under it.
35
+ The left column shows one section at a time. A row of tabs at the top chooses it: **Agenda**, **Goals**, **Jobs** and **Mentions**. Agenda is the first one shown. The column remembers your last choice in this browser. When you select a job, a goal or a mention, the column goes to its tab. The Agenda tab holds the day's meetings and, on a press, the month. A finished job shows its draft summary, or the reason it failed, inside the job. The **Goals** tab holds your goals, and each goal lists its jobs (see [Goals own jobs](#goals-own-jobs)). The day is open when you go to Agenda, also before you press **Prepare my day**. The other things that wait for you, such as outcome checks, report drafts and sessions, are jobs too. Each one is a row of **Needs you** in the jobs. Press it to see its card, with its own buttons (for example **Draft now**), in the detail column. When the day is folded, they show as cards under it.
36
36
 
37
37
  ### The Agenda month
38
38
 
39
- The menu calls this page **Agenda**. At the top of the left column is the month, with today marked. When a day shows no meetings yet, press **Prepare my day** (or **Prepare again**): the morning-brief session reads your calendar and fills the month. Each week starts with its number, for example W41.
39
+ The menu calls this page **Agenda**. It opens on the meetings of the day, with the arrows to go to another day. The month is hidden. Press **Calendar** to show it across the full width, and **Hide calendar** to put it away. Picking a day closes the month and shows that day's meetings. The page does not remember the day you picked or whether the month was open. When a day shows no meetings yet, press **Prepare my day** (or **Prepare again**): the morning-brief session reads your calendar and fills the month. Each week starts with its number, for example W41.
40
40
 
41
- - Press a day to see its agenda under the month. Three tiles open the agenda: the time in meetings, the meeting that matters most, and your prep.
42
41
  - Press a week number to see the agenda of that week, day by day.
43
42
  - Press a meeting to see its memory in the right column. The month marks every day of the same meeting, so you see when it happens again.
44
43
 
45
44
  Meetings that repeat and have nothing for you fold into one line, for example "+4 repeating, nothing for you". Press it to see them.
46
45
 
47
- Each day of the month answers three questions:
46
+ A day in the month holds three things: the day number, a tint that is darker when the day has more meetings, and a ★ when the day has a key meeting. A meeting matters when you present or decide, when people from outside the company or leadership join, or when 8 people or more join. Meeting titles, the prep badge and the notes count are in the day's agenda, not in the month.
48
47
 
49
- - **How full is the day?** The bar covers 09:00 to 18:00. Each block is a meeting. A dark block is an important meeting.
50
- - **What matters?** The ★ line names the one meeting of the day that matters. A meeting matters when you present or decide, when people from outside the company or leadership join, or when 8 people or more join. A meeting whose prep is at risk also shows.
51
- - **How ready are you?** The badge shows the prep steps done out of the steps needed, for example 1/3. It is red when the free time before the meeting is shorter than 30 minutes for each step left. A past day shows how many one-to-ones and important meetings have no notes.
48
+ A meeting's detail shows one focus. Under the title is one line: the time, how many people join, and a star when the meeting is key. Under it are the goal of the meeting and, for a meeting with prep, one line of progress (the steps done, a bar, the free minutes before the meeting). Only the first open step is expanded, with what its source says, its link and, for a note step, a box for your note. The one primary button follows it: **I have read it**, **Mark done** or **Done**, then **Mark ready** when every step is done, then **Take notes** once the meeting has started. Press the arrow at the end of another step to open it. **More** holds the rare presses: **Mark ready** while steps are left, **Remind me**, **Skip prep**, the key mark and **Add as a goal for today**. **Details** holds the people, the kind of meeting and how often it met, the initiative, During and After, the mail and the links. When the meeting has a goal, the detail also shows what the goal is done when and the goal's jobs, with the buttons to add a job and to link one.
52
49
 
53
- 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).
54
-
55
- A meeting's detail shows one focus. Under the title is one line: the time, how many people join, and a star when the meeting is key. Under it are the goal of the meeting and, for a meeting with prep, one line of progress (the steps done, a bar, the free minutes before the meeting). Only the first open step is expanded, with what its source says, its link and, for a note step, a box for your note. The one primary button follows it: **I have read it**, **Mark done** or **Done**, then **Mark ready** when every step is done, then **Take notes** once the meeting has started. Press the arrow at the end of another step to open it. **More** holds the rare presses: **Mark ready** while steps are left, **Remind me**, **Skip prep**, the key mark and **Add as a goal for today**. **Details** holds the people, the kind of meeting and how often it met, the initiative, the goal it counts toward, During and After, the mail and the links.
56
-
57
- 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. PMOS starts to listen at once and keeps the note small at the bottom right. Press it to open the note as a pop-up. When the meeting has a note, the detail shows its summary and **Open notes**, which opens it in the pop-up, and the meeting's row shows **notes**. See [Notes](notes.md).
50
+ 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. PMOS starts to listen at once and shows the note in Notes. When the meeting has a note, the detail shows its summary and **Open notes**, which shows it in Notes, and the meeting's row shows **notes**. See [Notes](notes.md).
58
51
 
59
52
  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.
60
53
 
@@ -92,6 +85,22 @@ Each job has one of three kinds.
92
85
 
93
86
  A draft that waits for you shows its summary, its text and the link of the place it is for. **Approve** sends only that text, to that place. **Revise** takes a note of at most 500 characters and asks for a new draft. You can revise a job 3 times. A fourth revise is refused, so do the job yourself or drop it. A failed job shows its reason and **Retry**.
94
87
 
88
+ ### Goals own jobs
89
+
90
+ A goal is something you want to finish. Add one in the **Goals** tab: type it and press Enter. **Add context** is optional and takes three fields: what is true when the goal is done (**done when**), what the goal serves (a key result or a health budget), and when it is due. A goal with no **done when** shows **Add done when**.
91
+
92
+ Each goal lists its jobs. A job belongs to one goal at most, and a job can have no goal. A job comes from a step of the goal, from preparing, from a mention, or from you. Its detail names where it came from and has a **Goal** list: pick a goal to link the job, or **No goal** to unlink it. The **Goals** tab ends with **Jobs without a goal**, each with the same list. To add a job to a goal, type it in the goal and press **Add job**. A job you add this way is yours: you check it and remove it, and no agent works on it.
93
+
94
+ The goal row shows how many jobs are done, for example "2 of 4 jobs done". That is information only. A goal is never closed for you. Press **Done** when the goal is finished, or **Drop** to give it up. **Add as a goal** in a meeting's **More** menu makes a goal from the meeting.
95
+
96
+ ### Who acts on a job
97
+
98
+ Each job says who acts: **Waiting on you**, **Agent is working** or **Agent finished**. A filled square marks the agent and an open square marks you. Every button on a job has a small **YOU** or **AGENT** tag. A button with **AGENT** starts the agent in a session, for example **Have the agent draft it**, **Revise** and **Retry**. A button with **YOU** is a press that you do yourself, for example **Approve** and **Mark done**. When a job has a session, whoever acted last, **Open session** opens it. A finished job shows its result inside the job.
99
+
100
+ ### Mentions
101
+
102
+ The **Mentions** tab lists what people asked of you. A mention from Slack, Confluence or Figma shows as a card in the layout of that service: a Slack message with the sender and the text; a Confluence comment with the page and, when the brief has one, the passage it is about; a Figma comment with the file it is on. The card shows only what the brief has. It never invents a reaction, a reply count or a picture. Press a mention to open its job. **Open in …** opens the conversation in the service. Nothing in PMOS posts to Slack, Confluence or Figma, resolves a comment or reacts. Mentions from other places show as a plain row.
103
+
95
104
  ### Jobs you do and meetings you prepare for
96
105
 
97
106
  A job that only you can do starts with a verb, such as **Reply**, **Review** or **Sign off**, and one sentence that says what to do. Under the sentence are who asked, why it is for today, and the steps. Check each step when you finish it. The first button opens the place where you do the job, for example **Reply in Slack**. **Have the agent draft it** gives the job to an agent. The draft then waits for your approval, as for any approve job.
@@ -173,10 +173,10 @@ function eventExtras(e, at, sources) {
173
173
  function itemExtras(x, at) {
174
174
  const f = {};
175
175
  const err = optLink(x.link, `${at}: link`) || optText(x.where, 120, `${at}: where`) || optText(x.who, 120, `${at}: who`) || optText(x.about, 480, `${at}: about`)
176
- || optText(x.ask, 240, `${at}: ask`) || optText(x.how, 480, `${at}: how`);
176
+ || optText(x.ask, 240, `${at}: ask`) || optText(x.quote, 280, `${at}: quote`) || optText(x.how, 480, `${at}: how`);
177
177
  if (err) return { error: err };
178
178
  if (x.link) f.link = x.link;
179
- for (const [k, n] of [['where', 120], ['who', 120], ['about', 480], ['ask', 240], ['how', 480]]) if (typeof x[k] === 'string' && x[k].trim()) f[k] = clip(x[k], n);
179
+ for (const [k, n] of [['where', 120], ['who', 120], ['about', 480], ['ask', 240], ['quote', 280], ['how', 480]]) if (typeof x[k] === 'string' && x[k].trim()) f[k] = clip(x[k], n);
180
180
  if (x.reply_needed !== undefined && x.reply_needed !== null) {
181
181
  if (typeof x.reply_needed !== 'boolean') return { error: `${at}: reply_needed must be true or false` };
182
182
  f.reply_needed = x.reply_needed;
@@ -19,18 +19,24 @@
19
19
  * | { action: 'step', id, step, done } step: the step's text, done: true | false
20
20
  * | { action: 'note', id, text } the PM's own note, written in the card (at most 1000
21
21
  * characters; empty clears it). It stays on this machine.
22
+ * | { action: 'link', id, goal } goal: the id of one of today's open goals, or null for "no goal".
23
+ * It overrides the goal the item or task came with (initiative
24
+ * today-goals-jobs, PRD L1). An item or a task, never a meeting.
22
25
  * | { action: 'restore', id, mark } mark: a `prev` an earlier edit returned, or null (Undo)
23
- * known: Map of today's ids → 'item' | 'event'. An id not in it is refused.
26
+ * known: Map of today's ids → 'item' | 'event' | 'task'. An id not in it is refused.
27
+ * goals: Set of the ids of today's open goals, for `link`.
24
28
  * effective(rec, now) → { id: mark } with a snooze or hold whose time has passed read as open
29
+ * links(rec) → { id: goalId | '' } for the marks that link a job ('' is "no goal")
25
30
  *
26
31
  * A mark lasts for its day: brief item ids are per day (lib/brief.js), so a snooze or a hold ends at midnight.
27
32
  */
28
33
 
29
34
  const STATES = ['done', 'snoozed', 'waiting', 'dismissed'];
30
- const ACTIONS = ['done', 'reopen', 'snooze', 'hold', 'dismiss', 'step', 'note', 'restore'];
31
- const KINDS = ['item', 'event'];
35
+ const ACTIONS = ['done', 'reopen', 'snooze', 'hold', 'dismiss', 'step', 'note', 'link', 'restore'];
36
+ const KINDS = ['item', 'event', 'task'];
37
+ const GOAL_ID_RE = /^g-[0-9a-f]{8}$/;
32
38
  const REASONS = { item: ['no-action', 'someone-else', 'done-elsewhere'], event: ['nothing', 'elsewhere'] };
33
- const ID_RE = /^[0-9a-f]{12}$/;
39
+ const ID_RE = /^(t-)?[0-9a-f]{12}$/;
34
40
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
35
41
  const MAX_IDS = 400;
36
42
  const MAX_STEPS = 12;
@@ -57,7 +63,8 @@ function cleanMark(m, date) {
57
63
  out.until = iso(t);
58
64
  }
59
65
  if (state === 'waiting') { const on = clip(m.on, MAX_ON); if (!on) return null; out.on = on; }
60
- if (state === 'dismissed') { if (!REASONS[m.kind].includes(m.why)) return null; out.why = m.why; }
66
+ if (state === 'dismissed') { if (!(REASONS[m.kind] || []).includes(m.why)) return null; out.why = m.why; }
67
+ if (m.kind !== 'event' && (m.goal === '' || GOAL_ID_RE.test(String(m.goal)))) out.goal = m.goal;
61
68
  const steps = Array.isArray(m.steps) ? [...new Set(m.steps.map((s) => clip(s, MAX_STEP)).filter(Boolean))].slice(0, MAX_STEPS) : [];
62
69
  if (steps.length) out.steps = steps;
63
70
  if (typeof m.note === 'string' && m.note.trim()) out.note = m.note.trim().slice(0, MAX_NOTE);
@@ -85,7 +92,7 @@ function laterToday(v, now, date) {
85
92
  return { at: t };
86
93
  }
87
94
 
88
- function edit(rec, op, { now = Date.now(), known } = {}) {
95
+ function edit(rec, op, { now = Date.now(), known, goals } = {}) {
89
96
  if (!rec || !DATE_RE.test(String(rec.date)) || !rec.marks || typeof rec.marks !== 'object') return { ok: false, error: 'no marks record to edit' };
90
97
  if (!op || typeof op !== 'object' || !ACTIONS.includes(op.action)) return { ok: false, error: `action must be one of ${ACTIONS.join(', ')}` };
91
98
  const id = String(op.id || '');
@@ -93,6 +100,7 @@ function edit(rec, op, { now = Date.now(), known } = {}) {
93
100
  const kind = known && typeof known.get === 'function' ? known.get(id) : null;
94
101
  if (!KINDS.includes(kind)) return { ok: false, error: 'no item or meeting with that id in today\'s brief' };
95
102
  if (localDate(now) !== rec.date) return { ok: false, error: `the marks are for ${rec.date}, and today is ${localDate(now)}` };
103
+ if (kind === 'task' && !['link', 'restore'].includes(op.action)) return { ok: false, error: 'only a link is kept for a task' };
96
104
  const marks = { ...rec.marks };
97
105
  const prev = marks[id] ? JSON.parse(JSON.stringify(marks[id])) : null;
98
106
  const cur = prev ? { ...prev } : { kind, state: null, at: null };
@@ -110,7 +118,12 @@ function edit(rec, op, { now = Date.now(), known } = {}) {
110
118
  marks[id] = c;
111
119
  return { ok: true, rec: { date: rec.date, marks }, mark: c, prev };
112
120
  }
113
- if (op.action === 'step') {
121
+ if (op.action === 'link') {
122
+ if (kind === 'event') return { ok: false, error: 'a meeting is not linked to a goal here: make the meeting a goal instead' };
123
+ const goal = op.goal === null || op.goal === undefined || op.goal === '' ? '' : String(op.goal);
124
+ if (goal && (!GOAL_ID_RE.test(goal) || !(goals && typeof goals.has === 'function' && goals.has(goal)))) return { ok: false, error: 'goal must be the id of an open goal of today, or null' };
125
+ cur.goal = goal;
126
+ } else if (op.action === 'step') {
114
127
  const step = clip(op.step, MAX_STEP);
115
128
  if (!step) return { ok: false, error: 'step must be the text of a step' };
116
129
  if (typeof op.done !== 'boolean') return { ok: false, error: 'done must be true or false' };
@@ -146,7 +159,7 @@ function edit(rec, op, { now = Date.now(), known } = {}) {
146
159
  }
147
160
  if (!marks[id] && Object.keys(marks).length >= MAX_IDS) return { ok: false, error: `at most ${MAX_IDS} marks a day` };
148
161
  const clean = cleanMark(cur, rec.date);
149
- if (clean.state === null && !clean.steps && !clean.note) delete marks[id]; else marks[id] = clean;
162
+ if (clean.state === null && !clean.steps && !clean.note && clean.goal === undefined) delete marks[id]; else marks[id] = clean;
150
163
  return { ok: true, rec: { date: rec.date, marks }, mark: marks[id] || null, prev };
151
164
  }
152
165
 
@@ -160,4 +173,11 @@ function effective(rec, now = Date.now()) {
160
173
  return out;
161
174
  }
162
175
 
163
- module.exports = { STATES, ACTIONS, REASONS, MAX_ON, MAX_STEPS, MAX_NOTE, emptyMarks, readMarks, edit, effective };
176
+ /** { id: goalId | '' } for the marks that link a job to a goal (or, with '', unlink it from the goal it came with). */
177
+ function links(rec) {
178
+ const out = {};
179
+ for (const [id, m] of Object.entries((rec && rec.marks) || {})) if (m.goal !== undefined) out[id] = m.goal;
180
+ return out;
181
+ }
182
+
183
+ module.exports = { STATES, ACTIONS, REASONS, MAX_ON, MAX_STEPS, MAX_NOTE, emptyMarks, readMarks, edit, effective, links };
@@ -35,7 +35,14 @@ const crypto = require('crypto');
35
35
  const MAX_GOALS = 7;
36
36
  const MAX_TEXT = 140;
37
37
  const STATUSES = ['open', 'done', 'dropped'];
38
- const ACTIONS = ['add', 'done', 'drop', 'reopen', 'remove', 'tag'];
38
+ const ACTIONS = ['add', 'done', 'drop', 'reopen', 'remove', 'tag', 'context', 'job-add', 'job-done', 'job-reopen', 'job-remove'];
39
+ const MAX_JOBS = 12; // jobs the PM types on one goal (initiative today-goals-jobs, PRD G3)
40
+ const MAX_JOB = 140;
41
+ const MAX_DONE_WHEN = 200;
42
+ const MAX_ANCHOR = 80;
43
+ const MAX_DUE = 40;
44
+ const JOB_ID_RE = /^j-[0-9a-f]{8}$/;
45
+ const EVENT_ID_RE = /^[0-9a-f]{12}$/;
39
46
  const REPO_RE = /^[A-Za-z0-9._-]{1,80}$/;
40
47
  const repoOrNull = (r) => (typeof r === 'string' && REPO_RE.test(r) ? r : null);
41
48
  const INI_RE = /^[a-z0-9][a-z0-9._-]*$/i;
@@ -47,6 +54,22 @@ const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().sl
47
54
 
48
55
  const emptyDay = (date) => ({ date, goals: [] });
49
56
 
57
+ /** The jobs the PM typed on a goal, checked: { id, text, done, added_at }. */
58
+ function cleanJobs(raw) {
59
+ if (!Array.isArray(raw)) return [];
60
+ return raw.filter((j) => j && typeof j === 'object' && JOB_ID_RE.test(j.id) && typeof j.text === 'string' && j.text.trim()).slice(0, MAX_JOBS)
61
+ .map((j) => ({ id: j.id, text: clip(j.text, MAX_JOB), done: j.done === true, added_at: typeof j.added_at === 'string' ? j.added_at : null }));
62
+ }
63
+ /** The optional context of a goal (PRD G1): what finished looks like, what it serves, when, and the meeting it came from. */
64
+ function cleanContext(g) {
65
+ const out = {};
66
+ const dw = clip(g.done_when, MAX_DONE_WHEN); if (dw) out.done_when = dw;
67
+ const an = clip(g.anchor, MAX_ANCHOR); if (an) out.anchor = an;
68
+ const du = clip(g.due, MAX_DUE); if (du) out.due = du;
69
+ if (typeof g.event_id === 'string' && EVENT_ID_RE.test(g.event_id)) out.event_id = g.event_id;
70
+ return out;
71
+ }
72
+
50
73
  /** A day record read from disk, checked. Anything that is not a day record for `date` reads as an empty day. */
51
74
  function readDay(raw, date) {
52
75
  if (!raw || typeof raw !== 'object' || raw.date !== date || !Array.isArray(raw.goals)) return emptyDay(date);
@@ -54,7 +77,7 @@ function readDay(raw, date) {
54
77
  && STATUSES.includes(g.status)).slice(0, MAX_GOALS).map((g) => ({
55
78
  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,
56
79
  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,
57
- repo: repoOrNull(g.repo),
80
+ repo: repoOrNull(g.repo), ...cleanContext(g), jobs: cleanJobs(g.jobs),
58
81
  }));
59
82
  return { date, goals };
60
83
  }
@@ -63,7 +86,7 @@ function readDay(raw, date) {
63
86
  * dropped, not until midnight. `prev` is that day's record (readDay); a day with its own file never takes these. */
64
87
  function carryOver(prev, date) {
65
88
  if (!prev || !Array.isArray(prev.goals) || !prev.date || prev.date >= date) return emptyDay(date);
66
- return { date, goals: prev.goals.filter((g) => g.status === 'open').slice(0, MAX_GOALS).map((g) => ({ ...g, closed_at: null })) };
89
+ return { date, goals: prev.goals.filter((g) => g.status === 'open').slice(0, MAX_GOALS).map((g) => ({ ...g, closed_at: null, jobs: cleanJobs(g.jobs).filter((j) => !j.done) })) };
67
90
  }
68
91
 
69
92
  /** One edit of the day's goals. A refused edit returns the reason and the caller writes nothing. */
@@ -79,10 +102,14 @@ function edit(day, op, { now = Date.now() } = {}) {
79
102
  const ini = op.initiative_id == null || op.initiative_id === '' ? null : op.initiative_id;
80
103
  if (ini !== null && (typeof ini !== 'string' || !INI_RE.test(ini))) return { ok: false, error: 'initiative_id is not an initiative id' };
81
104
  if (op.repo != null && op.repo !== '' && !repoOrNull(op.repo)) return { ok: false, error: 'repo is not a workspace id' };
105
+ for (const [k, n] of [['done_when', MAX_DONE_WHEN], ['anchor', MAX_ANCHOR], ['due', MAX_DUE]]) {
106
+ if (op[k] != null && (typeof op[k] !== 'string' || op[k].trim().length > n)) return { ok: false, error: `${k} is text of at most ${n} characters` };
107
+ }
108
+ if (op.event_id != null && op.event_id !== '' && !EVENT_ID_RE.test(String(op.event_id))) return { ok: false, error: 'event_id is not a meeting id' };
82
109
  const text = clip(op.text, MAX_TEXT);
83
110
  if (goals.some((g) => g.text.toLowerCase() === text.toLowerCase())) return { ok: false, error: 'that goal is already on the list' };
84
111
  const id = 'g-' + crypto.createHash('sha1').update(`${day.date}|${text}|${at}|${goals.length}`).digest('hex').slice(0, 8);
85
- goals.push({ id, text, initiative_id: ini, status: 'open', added_at: at, closed_at: null, repo: repoOrNull(op.repo) });
112
+ goals.push({ id, text, initiative_id: ini, status: 'open', added_at: at, closed_at: null, repo: repoOrNull(op.repo), ...cleanContext(op), jobs: [] });
86
113
  return { ok: true, day: { date: day.date, goals }, goal: goals[goals.length - 1] };
87
114
  }
88
115
  const i = goals.findIndex((g) => g.id === op.id);
@@ -94,6 +121,34 @@ function edit(day, op, { now = Date.now() } = {}) {
94
121
  g.repo = repoOrNull(op.repo);
95
122
  return { ok: true, day: { date: day.date, goals }, goal: g };
96
123
  }
124
+ if (op.action === 'context') {
125
+ for (const [k, n] of [['done_when', MAX_DONE_WHEN], ['anchor', MAX_ANCHOR], ['due', MAX_DUE]]) {
126
+ if (op[k] === undefined) continue;
127
+ if (typeof op[k] !== 'string' || op[k].trim().length > n) return { ok: false, error: `${k} is text of at most ${n} characters` };
128
+ const v = clip(op[k], n); if (v) g[k] = v; else delete g[k];
129
+ }
130
+ return { ok: true, day: { date: day.date, goals }, goal: g };
131
+ }
132
+ if (op.action.startsWith('job-')) {
133
+ g.jobs = cleanJobs(g.jobs);
134
+ if (op.action === 'job-add') {
135
+ if (typeof op.text !== 'string' || !op.text.trim()) return { ok: false, error: 'a job needs text' };
136
+ if (op.text.trim().length > MAX_JOB) return { ok: false, error: `a job is at most ${MAX_JOB} characters` };
137
+ if (g.jobs.length >= MAX_JOBS) return { ok: false, error: `a goal has at most ${MAX_JOBS} jobs you typed` };
138
+ const text = clip(op.text, MAX_JOB);
139
+ if (g.jobs.some((j) => j.text.toLowerCase() === text.toLowerCase())) return { ok: false, error: 'that job is already on this goal' };
140
+ const id = 'j-' + crypto.createHash('sha1').update(`${g.id}|${text}|${at}|${g.jobs.length}`).digest('hex').slice(0, 8);
141
+ g.jobs.push({ id, text, done: false, added_at: at });
142
+ return { ok: true, day: { date: day.date, goals }, goal: g, job: g.jobs[g.jobs.length - 1] };
143
+ }
144
+ const j = g.jobs.find((x) => x.id === op.job);
145
+ if (!j) return { ok: false, error: 'no job with that id on this goal' };
146
+ if (op.action === 'job-remove') { g.jobs = g.jobs.filter((x) => x !== j); return { ok: true, day: { date: day.date, goals }, goal: g, job: j }; }
147
+ const want = op.action === 'job-done';
148
+ if (j.done === want) return { ok: false, error: `that job is already ${want ? 'done' : 'open'}` };
149
+ j.done = want;
150
+ return { ok: true, day: { date: day.date, goals }, goal: g, job: j };
151
+ }
97
152
  const to = { done: 'done', drop: 'dropped', reopen: 'open' }[op.action];
98
153
  if (g.status === to) return { ok: false, error: `that goal is already ${to}` };
99
154
  g.status = to; g.closed_at = to === 'open' ? null : at;
@@ -127,6 +182,9 @@ function freeTime(events, at, endOfDay = END_OF_DAY) {
127
182
  return { free_minutes: Math.max(0, Math.round((end - now - busy) / 60000)), until: endOfDay, next: upcoming ? { start: upcoming.start, title: upcoming.title } : null };
128
183
  }
129
184
 
185
+ /** The goal id an item is under: the PM's link mark when there is one ('' is "no goal"), else the item's own. */
186
+ const goalOf = (x, links) => (links && Object.prototype.hasOwnProperty.call(links, x.id) ? links[x.id] || null : x.goal || null);
187
+
130
188
  const stepOrder = (a, b) => (a.owner === b.owner ? 0 : a.owner === 'pm' ? -1 : 1) || (b.confidence - a.confidence) || a.title.localeCompare(b.title);
131
189
 
132
190
  // The context a job carries (G18) rides on its step unchanged, so Today and the CLI show what the brief said.
@@ -153,7 +211,7 @@ function cardStep(c) {
153
211
  * now ms; the clock the server passes
154
212
  * A brief for another day is not joined: it is named as out of date, and only the Today cards plan the goals.
155
213
  */
156
- function planDay({ day, brief = null, cards = [], now = Date.now(), endOfDay = END_OF_DAY } = {}) {
214
+ function planDay({ day, brief = null, cards = [], now = Date.now(), endOfDay = END_OF_DAY, links = {} } = {}) {
157
215
  const date = day && day.date;
158
216
  const fresh = !!brief && brief.date === date;
159
217
  const items = fresh ? [...(brief.priorities || []), ...(brief.worth_asking || [])] : [];
@@ -168,8 +226,8 @@ function planDay({ day, brief = null, cards = [], now = Date.now(), endOfDay = E
168
226
  // G9: a step goes only under an OPEN goal. A done or dropped goal draws no steps, so an item that names one is
169
227
  // placed as if it named no goal (by initiative, else "also" or "worth asking"); it is never lost under a closed goal.
170
228
  for (const x of items) {
171
- const g = (x.goal && openById.get(x.goal)) || byIni(x.initiative_id);
172
- if (g) g.steps.push(itemStep(x)); else unplaced.items.push(x);
229
+ const g = (goalOf(x, links) && openById.get(goalOf(x, links))) || byIni(x.initiative_id);
230
+ if (g) g.steps.push(itemStep({ ...x, goal: goalOf(x, links) })); else unplaced.items.push(x);
173
231
  }
174
232
  for (const c of cards || []) {
175
233
  const g = byIni(c.initiative_id);
@@ -217,14 +275,14 @@ const placeNameKeys = (n) => { const t = String(n || '').trim().toLowerCase().re
217
275
 
218
276
  /** { itemId: repo } for today's brief items: the repository of the open goal an item is under, else of the initiative
219
277
  * it names (`iniRepos`: { initiativeId: repo }, only the ids one repository holds), else of its place. */
220
- function itemRepos({ day, brief, placeRepos = {}, iniRepos = {} } = {}) {
278
+ function itemRepos({ day, brief, placeRepos = {}, iniRepos = {}, links = {} } = {}) {
221
279
  const out = {};
222
280
  if (!brief || !day || brief.date !== day.date) return out;
223
281
  const open = (day.goals || []).filter((g) => g.status === 'open');
224
282
  const byId = new Map(open.map((g) => [g.id, g]));
225
283
  const byIni = (ini) => (ini ? open.find((g) => g.initiative_id === ini) || null : null);
226
284
  for (const x of [...(brief.priorities || []), ...(brief.worth_asking || [])]) {
227
- const g = (x.goal && byId.get(x.goal)) || byIni(x.initiative_id);
285
+ const g = (goalOf(x, links) && byId.get(goalOf(x, links))) || byIni(x.initiative_id);
228
286
  let r = g ? repoOrNull(g.repo) || (g.initiative_id ? repoOrNull(iniRepos[g.initiative_id]) : null) : null;
229
287
  if (!r && x.initiative_id) r = repoOrNull(iniRepos[x.initiative_id]);
230
288
  if (!r && x.where) {
@@ -243,4 +301,4 @@ function goalRepos({ day, iniRepos = {} } = {}) {
243
301
  return out;
244
302
  }
245
303
 
246
- module.exports = { MAX_GOALS, MAX_TEXT, STATUSES, ACTIONS, GOAL_ID_RE, START_OF_DAY, END_OF_DAY, emptyDay, readDay, carryOver, edit, freeTime, planDay, itemRepos, goalRepos };
304
+ module.exports = { MAX_GOALS, MAX_TEXT, MAX_JOBS, MAX_JOB, MAX_DONE_WHEN, MAX_ANCHOR, MAX_DUE, STATUSES, ACTIONS, GOAL_ID_RE, START_OF_DAY, END_OF_DAY, emptyDay, readDay, carryOver, edit, freeTime, planDay, itemRepos, goalRepos };