create-pmos 0.54.0 → 0.55.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.54.0",
3
+ "version": "0.55.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@65fcbaf
1
+ kernel-source: pmos@058e723
2
2
  assembled: 2026-10-06
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-06T02:54:46Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T06:04:22Z"}
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-06T02:54:46Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T06:04:22Z"}
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-06T02:54:46Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T06:04:22Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -23,6 +23,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
23
23
  |---|---|
24
24
  | What is waiting for you now | Today |
25
25
  | The initiatives of a repository, or of every repository | Initiatives. Choose **All repositories** in Repository ▾ for every repository. |
26
+ | The branches of a repository and the sessions running in each | Repository ▾ → **Branches and sessions**. |
26
27
  | The items of an initiative | Board, or Breakdown in the initiative detail |
27
28
  | Run reports | Reports |
28
29
  | Repositories, Profiles, New sessions (the default CLI and model), Validate consent, Theme | Settings |
@@ -14,6 +14,7 @@ On a desktop screen the workspace is one frame. It has one top bar, the place on
14
14
  The top bar has these parts, from left to right:
15
15
 
16
16
  - **Repository ▾** names the current repository. Press it to choose another repository, or **All repositories** to see the work of every repository at once. All repositories replaces the old Portfolio mode.
17
+ - **Branches and sessions**, at the end of Repository ▾ (or in ⌘K), opens a sheet with every branch of the repository (main first) and how many sessions run in each. Press a branch to see its sessions, open one, end one, or start a session there. **+ branch** makes a branch, and **+ session** starts one with a branch, an agent and a profile. With All repositories, the sheet lists every repository.
17
18
  - **Today, Initiatives, Board and Reports** are the four places. Press one to show it. The Today count shows how many items need you.
18
19
  - **Search or capture ⌘K** opens the command palette. Type to find an initiative, a repository, an open tab or a command.
19
20
  - **Sync** shows only when the current repository is not in sync with its remote. Press it to fetch, bring in the work of the other machine, record your changes and push. If the repository has not fetched in the last 10 minutes, Sync also shows, because a push from another machine is not visible until a fetch. When the repository fetched recently and is in sync, Sync is not shown. If Sync stops, the message names the step that stopped.
@@ -15,8 +15,8 @@ At the top of Today you can list what you want done today. A goal is one line of
15
15
 
16
16
  - Open **Goals for today** and type a goal. Press Enter to add it. A day has at most 7 goals, each at most 140 characters.
17
17
  - Press **Done**, **Drop**, **Reopen** or **Remove** on a goal. A goal is open, done or dropped.
18
- - Press **Prepare my day**. The workspace makes the daily 06:45 morning-brief schedule if this repository has none, and runs it now. The session reads your goals, your calendar, and the mentions of you in Slack, Jira and Confluence. It ties each meeting, mention and step that moves a goal to that goal.
19
- - While the press runs, the button says **Starting…**. When the session starts, its terminal tab opens and the goals section says when it started; **Open the session** opens the tab again. If the session cannot start, the section says why in red.
18
+ - Press **Prepare my day**. The workspace makes the daily 06:45 morning-brief schedule if this repository has none, and runs it now. The session reads your goals, your calendar, and the sources you switched on. It ties each meeting, mention and step that moves a goal to that goal. A goal is not needed: the press works with no goals.
19
+ - While the press runs, the button says **Starting…**. When the session starts, its terminal tab opens and the goals section says when it started; **Open the session** opens the tab again. If a brief session is already running, the press starts nothing and says so. If the session cannot start, the section says why in red.
20
20
  - Each goal shows its steps: yours first, then the agent's. A step comes from the brief, or from a Today card on the goal's initiative. A goal with no step says so. The session does not invent a step.
21
21
  - A step is under a goal only when the brief names the goal, or when the step and the goal name the same initiative. The workspace never matches words.
22
22
  - An agent step that names an initiative has **Prepare**. It shows the step, the branch and the prompt, the same as background preparation. **Queue it** approves that exact prompt. Background preparation must be on in the settings. Otherwise the queue says so and nothing is queued.
@@ -30,15 +30,71 @@ At the top of Today you can list what you want done today. A goal is one line of
30
30
  The top of Today is the morning brief: a greeting, the day's calendar, the priorities and a list of things worth asking about. The brief is not computed by the workspace. A skill called morning-brief runs in an agent session. The session reads the sources, and then submits one brief with `pmos proactive brief submit`. The workspace server only checks the brief and stores it. It makes no model call and reaches no calendar or mailbox.
31
31
 
32
32
  - Press **schedule the morning brief** in Today to make a daily schedule at 06:45. The schedule is an ordinary scheduled task, so it runs only while the workspace server runs. A sleeping computer gets no brief. A schedule that is made after its time of day for the first time runs within a minute, so pressing the button at 10:00 starts one brief session straight away.
33
- - Each source is a setting: calendar, wispr, mail, slack, jira and confluence. Mail is off by default and the skill reads only subjects and senders, never bodies. Slack is on by default. In Slack, Jira and Confluence the skill reads the mentions of you: where, who, and a one-line summary. It never copies a message. A source that is off cannot be reported as read.
33
+ - Each source is a setting: calendar, wispr, mail, slack, jira, confluence, github, drive, figma and website. Calendar, wispr, slack, jira and confluence are on by default. Mail, github, drive, figma and website are off by default. The skill reads only subjects and senders of mail, never bodies. A source that is off cannot be reported as read.
34
+ - **Scopes.** For Slack, Jira and Confluence you give each channel, project or space one scope. At most 50 names are kept for each service, and a name has at most 120 characters. **watch** means the session reads every message since the last read and writes a digest: the decisions, the open questions, and what touches a goal. **search** means the session reads nothing there and looks only for mentions of you and the words of your goals. **ignore** means the session does not read, search or report the place.
35
+ - **Websites.** Under the website source you list at most 10 addresses. The session reads each one for changes that matter to a goal or a meeting.
36
+ - **Mentions.** In every place that is not ignored, the skill finds the mentions of you: where, who, the link to the conversation, and a short paraphrase. It never copies a message.
37
+ - **Meetings.** Each calendar event can carry a purpose and a prep note (at most 240 characters each), up to 6 steps before, during and after the meeting (each step says whether you or an agent does it), a suggested initiative with the reason, and up to 5 links.
38
+ - **Items.** An item can carry a link to the conversation itself, where it is, who is involved, what it is about, what is asked of you, whether a reply is needed, and how the session suggests to deal with it. A brief holds at most 30 items.
39
+ - **Jobs.** Each item has a kind. **you** is a job only you can do. **approve** is a job where an agent prepares one draft and you approve it before anything leaves. **auto** is a job an agent does with no outward step. A job for you is never approve or auto.
40
+ - **Digests.** A digest is the summary of one watched place: at most 5 decisions, 5 open questions and 5 lines on goals, and at most 30 digests in a brief. A digest is allowed only for a source that was read.
34
41
  - The brief names the day it is for. If that is not today, Today says it is out of date.
35
42
  - An item the session is less than 0.5 sure about is shown under worth asking, not as a priority. The workspace sets that bucket from the confidence. The session cannot choose it.
36
43
  - The order is fixed: higher confidence first, then your items before the agent's, then by title.
37
44
  - Each item takes the same feedback as a derived item: useful, not useful and wrong priority.
38
45
  - Typed scoring is off by default. When you switch it on, the skill sends titles and metadata to the Typesafe service to score them. The brief says whether it used scoring. The workspace cannot check that claim.
39
- - A brief is refused, with the reason and with nothing stored, when it has a wrong shape, an unknown source, a confidence outside 0 to 1, more than 12 items or 20 events, or a text field that is too long.
46
+ - A brief is refused, with the reason and with nothing stored, when it has a wrong shape, an unknown source, a confidence outside 0 to 1, more than 30 items or 20 events, a link that does not start with https:// or http://, or a text field that is too long.
40
47
  - The brief is stored in the workspace state folder on this computer. It is not committed.
41
48
 
49
+ ## The day run
50
+
51
+ **Prepare my day** starts a run for the day. The run goes from the brief to the delivery of every agent job. You press nothing until a draft waits for you.
52
+
53
+ 1. A brief session reads the sources and submits the brief. The run exists from the moment you press.
54
+ 2. Each item that an agent owns, and whose kind is approve or auto, becomes a task. A task is waiting, working, review, sending, sent, done or failed.
55
+ 3. The workspace starts a session for each waiting task, oldest first. It runs at most 5 sessions at once. The rest wait.
56
+ 4. The session uses the day-task skill. It reads the sources read-only, makes one draft, and delivers it. It sends, posts, replies, comments, transitions, merges and pushes nothing.
57
+ 5. An **auto** task is then done. An **approve** task moves to review. The draft shows its summary, its text and its link.
58
+ 6. You press **Approve**, **Revise** or, for a failed task, **Retry**.
59
+ - **Approve** starts a new session in send mode. It does the one outward step of the approved draft, for example it posts the reply in the linked thread, and nothing more. It then reports the link of what it sent. The task is sent. If the step cannot be done, the task fails with the reason.
60
+ - **Revise** needs a note of 1 to 500 characters. The task goes back to waiting, and a session in revise mode changes the draft to meet the note. A task can be revised 3 times. A fourth revise is refused.
61
+ - **Retry** puts a failed task back to waiting.
62
+ 7. A session that ends with no draft makes its task fail with that reason. A send session that ends before it reports sent makes its task fail too.
63
+
64
+ The page shows the stage of the day: reading (no brief yet), working (a task waits, works or sends), review (a draft waits for you) or done.
65
+
66
+ Only the PM's own press approves a draft. The day-task session cannot approve for you. The factory rule is unchanged: no session of the run holds push or merge rights, and it writes no control-graph record.
67
+
68
+ ## The daytime check
69
+
70
+ The brief is made once, early. The daytime check keeps it true through the day. A check is a session that uses the day-check skill. It reads only what changed since the last check: today's calendar, mentions since the last check, the watched places since then, and, for a meeting that ended, the Wispr Flow notes and the related mail. It merges the result into the stored brief:
71
+
72
+ - new items are added and marked new,
73
+ - items that are done are taken off, and listed as resolved,
74
+ - notes from a meeting that ended are added to that event,
75
+ - the digests of the places it read are replaced.
76
+
77
+ A check never sends anything. After a merge the brief still holds at most 30 items, or the merge is refused.
78
+
79
+ When a check runs, the workspace decides from the settings:
80
+
81
+ | Setting | Meaning |
82
+ |---|---|
83
+ | every | 0 turns checks off. The default is 60 minutes. The choices are 30, 60 and off. |
84
+ | start and end | Checks run only between these times. The defaults are 09:00 and 18:00. |
85
+
86
+ A check is due for one of three reasons.
87
+
88
+ - **interval.** The time since the last check is at least the interval.
89
+ - **before a meeting.** A meeting starts within 15 minutes, and no check has run since 15 minutes before it.
90
+ - **after a meeting.** A meeting ended since the last check. This runs once for each meeting.
91
+
92
+ **Backoff.** A check that finds nothing new and resolves nothing is an empty check. Each empty check in a row doubles the wait, up to 120 minutes. A check that finds or resolves something sets the wait back to the interval. For that reason the session submits an empty merge when nothing changed, so the check counts.
93
+
94
+ No check is due when no run exists for the day, when a check is running, when checks are off, or outside the start and end times. **Check now** starts a check at once for the run of the day. It is refused when a check is running or when there is no run.
95
+
96
+ The page shows when the last check ran, when the next is due, and a log of the last 20 checks with the reason, how many items they found and how many they resolved.
97
+
42
98
  ## Background preparation
43
99
 
44
100
  From a Today item that names an initiative, you can ask for the plan and approve it. The workspace then starts that session itself, later. It uses the same path as the next-step go button, so the plan, the prompt, the propose-only rules and the session budget are the same.
@@ -101,7 +157,10 @@ A verdict never changes a status, a lifecycle record or an acceptance. It is rec
101
157
  |---|---|
102
158
  | engine on | Off stops automatic scans. The scan now button still works. |
103
159
  | watch: needs, returned, review, schedule, stalled | Turn one trigger off. |
104
- | brief sources | One switch for each of calendar, wispr, mail, slack, jira and confluence. |
160
+ | brief sources | One switch for each of calendar, wispr, mail, slack, jira, confluence, github, drive, figma and website. |
161
+ | scopes | For Slack, Jira and Confluence, a scope for each channel, project or space: watch, search or ignore. At most 50 names for each service. |
162
+ | websites | Up to 10 addresses that the website source reads. |
163
+ | daytime check | How often checks run (30 minutes, 60 minutes or off), and the start and end times between which they run. |
105
164
  | background prepare | Off by default. On lets the workspace start a session for a plan you approved. |
106
165
  | typed scoring | Off by default. On lets the skill send titles and metadata to the Typesafe service. |
107
166
  | name | Used in the greeting. |
@@ -117,9 +176,12 @@ Text that you type in a setting (the name, the stalled days, the quiet hours) or
117
176
 
118
177
  - `pmos proactive today` lists the items.
119
178
  - `pmos proactive scan` scans now.
120
- - `pmos proactive settings` shows the settings. Add `--enabled`, `--trigger NAME=on|off`, `--stalled-days`, `--quiet HH:MM-HH:MM|none`, `--pause-learning`, `--background-prepare on|off`, `--brief-source NAME=on|off`, `--brief-scoring on|off` or `--name` to change one.
179
+ - `pmos proactive settings` shows the settings. Add `--enabled`, `--trigger NAME=on|off`, `--stalled-days`, `--quiet HH:MM-HH:MM|none`, `--pause-learning`, `--background-prepare on|off`, `--brief-source NAME=on|off`, `--scope SERVICE:NAME=watch|search|ignore`, `--day-check 30|60|off`, `--website URL`, `--no-website URL`, `--brief-scoring on|off` or `--name` to change one.
121
180
  - `pmos proactive goals` lists today's goals with their steps. `pmos proactive goals add TEXT [--initiative ID]` adds one. `done N`, `drop N`, `reopen N` and `remove N` change the Nth goal. `pmos proactive goals plan` does the same as **Prepare my day**.
122
- - `pmos proactive brief` shows today's brief. `pmos proactive brief submit --file PATH` submits one. `pmos proactive brief schedule` makes the 06:45 schedule for the current repository.
181
+ - `pmos proactive brief` shows today's brief. `pmos proactive brief submit --file PATH` submits one. `pmos proactive brief submit --file PATH --merge` merges one into the stored brief of the same day. `pmos proactive brief schedule` makes the 06:45 schedule for the current repository.
182
+ - `pmos proactive day` shows the stage of the day and its tasks. `pmos proactive day show TASK` prints one task with its item, its draft and the notes of each revision. Sessions read it.
183
+ - `pmos proactive day deliver TASK --summary TEXT --body-file PATH [--link URL]` delivers the draft of a working task. `pmos proactive day sent TASK [--link URL]` reports that the approved step was done. `pmos proactive day fail TASK --reason TEXT` reports that a task cannot be done. Sessions run these three.
184
+ - `pmos proactive day approve TASK`, `pmos proactive day revise TASK --note TEXT` and `pmos proactive day retry TASK` are your decisions, the same as the buttons. `pmos proactive day check` starts a check now.
123
185
  - `pmos proactive prepare` shows the queue. `pmos proactive prepare plan --initiative ID` shows a plan and its hash. `pmos proactive prepare queue --initiative ID --item ITEM --sha HASH` approves it. `pmos proactive prepare cancel ID` cancels an entry. `pmos proactive prepare run` looks at the queue now.
124
186
  - `pmos proactive learn` shows the rollup, the weights, the narrowed triggers and the lessons. `pmos proactive learn run` takes the weekly review now. `pmos proactive learn lessons` lists the approved lessons. `pmos proactive learn approve ID`, `reject ID` and `retire ID` decide a lesson. `pmos proactive learn restore TRIGGER` brings back a narrowed trigger. `pmos proactive learn reset` makes it ignore earlier feedback.
125
187
  - `pmos proactive feedback <id> useful|not-useful|wrong-priority|opened|dismissed` records feedback. Use `--missed "text"` for a missed item.
@@ -127,7 +189,8 @@ Text that you type in a setting (the name, the stalled days, the quiet hours) or
127
189
  ## Limits
128
190
 
129
191
  - It runs only while the workspace server runs. It does not catch up for time the server was off. A sleeping computer gets no scan.
130
- - The items from the records come from this computer. The workspace itself does not read your mail, your calendar, Slack, Jira or Confluence. Only the morning-brief session does, and only the sources you switched on.
131
- - It does not start a session, push, send, post, accept or merge. It only shows items and records your feedback, and learns from it as described above. The one exception is background preparation, which starts a session only for a plan you approved, as described above.
192
+ - The items from the records come from this computer. The workspace itself does not read your mail, your calendar, Slack, Jira, Confluence, GitHub, Drive, Figma or a website. Only the morning-brief, day-task and day-check sessions do, and only the sources you switched on.
193
+ - It does not start a session, push, send, post, accept or merge. It only shows items and records your feedback, and learns from it as described above. There are two exceptions. Background preparation starts a session only for a plan you approved. The day run starts the brief, task and check sessions described above. In the day run the only outward step is the one in a draft you approved, and a session does it only in send mode.
194
+ - The day run and the daytime check run only while the workspace server runs. A check that was due while the server was off is not made up.
132
195
  - An unreadable repository is listed as not read. An empty list is not proof that nothing waits there.
133
196
  - The records stay in the workspace state folder on this computer. They are not committed.
@@ -1,12 +1,35 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Today: what needs you"
4
- description: "The eight kinds of card on Today, the one button on each card, the count, the empty state, and the pmos today command."
5
- timestamp: 2026-10-05
4
+ description: "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
+ timestamp: 2026-10-06
6
6
  ---
7
7
 
8
8
  # Today: what needs you
9
9
 
10
+ ## The day
11
+
12
+ Above the cards, Today shows the day in two columns.
13
+
14
+ - **You.** The jobs only you can do, and the meetings of the day, each with its purpose, what to prepare, and the steps before, during and after it.
15
+ - **Agents.** The jobs an agent does for you. Each job shows where it stands: waiting, working, review, sending, sent, done or failed.
16
+
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
+
19
+ Each job has one of three kinds.
20
+
21
+ | Kind | Who does it | What you do |
22
+ |---|---|---|
23
+ | you | Only you. | Do it. Nothing runs for it. |
24
+ | approve | An agent prepares one draft. | Read the draft. Press **Approve** to let the one outward step happen, or **Revise** with a note. |
25
+ | auto | An agent does it, and nothing leaves the computer. | Read the result. Press **Revise** if it is wrong. |
26
+
27
+ 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**.
28
+
29
+ The page also shows which sources were read and how many items each gave, and the next daytime check. **Check now** starts a check at once.
30
+
31
+ ## What waits for you
32
+
10
33
  Today lists only what waits for you. Each card is one thing that only you can close. When nothing waits, Today says so and names the next scheduled draft, for example "Nothing needs you. Next: biweekly draft Fri 16 Oct."
11
34
 
12
35
  Press **Today** in the top bar, or press `g` and then `t`, to open it. The number on the Today button is the number of cards in the scope you chose: the repository, or All repositories. It is the same number that Today shows at the top of the list.
@@ -14,21 +14,41 @@
14
14
  * `ctx.goalIds` given, a goal that is not in that day's record is refused. An item may say it is a `mention` of the PM
15
15
  * (Slack, Jira, Confluence, mail): a boolean, nothing more.
16
16
  *
17
+ * The day run (PRD G9, G17 to G22). A brief holds at most 30 items: priorities, worth asking and mentions share the
18
+ * limit, and 12 let the mentions of three services crowd out the priorities. An event may carry what the PM needs to
19
+ * walk into it (purpose, prep, steps before/during/after, a suggested initiative, links, and after it ends the Wispr
20
+ * Flow notes and the related mail). An item may carry the context a job needs (a link to the conversation itself,
21
+ * where, who, what is asked, how the agent will do it) and a kind: `you` (the PM acts), `approve` (the agent drafts,
22
+ * the PM approves before anything goes out) or `auto` (the agent finishes it alone, nothing leaves this machine).
23
+ * A `digest` sums up one watched Slack channel, Jira project or Confluence space. Each of these is still a short
24
+ * summary with a limit, refused past it, never a copy of the source.
25
+ *
26
+ * mergeBrief(stored, incoming, {now}) → {ok:true, brief, added, removed} | {ok:false, error}
27
+ *
28
+ * A daytime check submits only what changed, as a merge: new items are added and marked new, the items it names in
29
+ * `resolved` are taken off, event fields and digests are replaced, and the rest of the stored brief is kept.
30
+ *
17
31
  * The bucket is derived: an item under WORTH_ASKING confidence is "worth asking", whatever the session said. The order is
18
32
  * fixed and stated: higher confidence first, then PM-owned before agent-owned, then by title.
19
33
  */
20
34
  const crypto = require('crypto');
21
35
 
22
- const SOURCES = ['calendar', 'wispr', 'mail', 'slack', 'jira', 'confluence'];
36
+ const SOURCES = ['calendar', 'wispr', 'mail', 'slack', 'jira', 'confluence', 'github', 'drive', 'figma', 'website'];
23
37
  const ITEM_SOURCES = [...SOURCES, 'derived'];
24
38
  const SOURCE_STATES = ['read', 'not-reached', 'off'];
25
39
  const OWNERS = ['pm', 'agent'];
26
40
  const EFFORTS = ['minutes', 'an-hour', 'half-day', 'day'];
41
+ const KINDS = ['you', 'approve', 'auto'];
42
+ const DIGEST_SOURCES = ['slack', 'jira', 'confluence'];
43
+ const STEP_WHEN = ['before', 'during', 'after'];
27
44
  const WORTH_ASKING = 0.5;
28
- const MAX_ITEMS = 12;
45
+ const MAX_ITEMS = 30;
29
46
  const MAX_EVENTS = 20;
47
+ const MAX_DIGESTS = 30;
48
+ const MAX_RESOLVED = 200;
30
49
  const INI_RE = /^[a-z0-9][a-z0-9._-]*$/i;
31
50
  const GOAL_RE = /^g-[0-9a-f]{8}$/;
51
+ const ITEM_ID_RE = /^[0-9a-f]{12}$/;
32
52
  const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
33
53
 
34
54
  const pad = (n) => String(n).padStart(2, '0');
@@ -40,6 +60,129 @@ function greeting(now, name) {
40
60
  }
41
61
 
42
62
  const tooLong = (v, n) => typeof v === 'string' && v.trim().length > n;
63
+ const sha12 = (s) => crypto.createHash('sha1').update(s).digest('hex').slice(0, 12);
64
+
65
+ /** A link is an http(s) address of at most 500 characters. Anything else is refused, never repaired. */
66
+ const isUrl = (v) => typeof v === 'string' && v.length <= 500 && /^https?:\/\/\S+$/.test(v);
67
+
68
+ /* Small checks for the optional fields. Each returns an error string, or null when the value is absent or fine. */
69
+ function optText(v, n, what) {
70
+ if (v === undefined || v === null) return null;
71
+ if (typeof v !== 'string') return `${what} must be text`;
72
+ return tooLong(v, n) ? `${what} is over ${n} characters` : null;
73
+ }
74
+ function optLink(v, what) {
75
+ if (v === undefined || v === null) return null;
76
+ return isUrl(v) ? null : `${what} must be an http(s) link of at most 500 characters`;
77
+ }
78
+ function optLines(v, max, n, what) {
79
+ if (v === undefined || v === null) return null;
80
+ if (!Array.isArray(v)) return `${what} must be a list`;
81
+ if (v.length > max) return `${what} has ${v.length} lines — at most ${max}`;
82
+ for (const [i, x] of v.entries()) {
83
+ if (typeof x !== 'string' || !x.trim()) return `${what} line ${i + 1} must be text`;
84
+ if (tooLong(x, n)) return `${what} line ${i + 1} is over ${n} characters`;
85
+ }
86
+ return null;
87
+ }
88
+ const lines = (v, n) => (Array.isArray(v) ? v.map((x) => clip(x, n)) : []);
89
+
90
+ /* The meeting fields of one calendar event (G17). Returns {error} or {fields} with only what was given. */
91
+ function eventExtras(e, at, sources) {
92
+ const f = {};
93
+ for (const [k, n] of [['purpose', 240], ['prep', 240]]) {
94
+ const err = optText(e[k], n, `${at}: ${k}`); if (err) return { error: err };
95
+ if (typeof e[k] === 'string' && e[k].trim()) f[k] = clip(e[k], n);
96
+ }
97
+ if (e.steps !== undefined && e.steps !== null) {
98
+ if (typeof e.steps !== 'object' || Array.isArray(e.steps)) return { error: `${at}: steps must be {before, during, after}` };
99
+ for (const k of Object.keys(e.steps)) if (!STEP_WHEN.includes(k)) return { error: `${at}: steps has "${k}" — only ${STEP_WHEN.join(', ')}` };
100
+ const steps = {};
101
+ for (const k of STEP_WHEN) {
102
+ const list = e.steps[k];
103
+ if (list === undefined || list === null) continue;
104
+ if (!Array.isArray(list)) return { error: `${at}: steps.${k} must be a list` };
105
+ if (list.length > 6) return { error: `${at}: steps.${k} has ${list.length} steps — at most 6` };
106
+ steps[k] = [];
107
+ for (const [j, st] of list.entries()) {
108
+ const sa = `${at}: steps.${k} ${j + 1}`;
109
+ if (!st || typeof st !== 'object' || Array.isArray(st)) return { error: `${sa} is not an object` };
110
+ if (typeof st.t !== 'string' || !st.t.trim()) return { error: `${sa} needs t (the step)` };
111
+ if (tooLong(st.t, 140)) return { error: `${sa}: t is over 140 characters` };
112
+ if (!OWNERS.includes(st.who)) return { error: `${sa}: who must be one of ${OWNERS.join(', ')}` };
113
+ if (st.done !== undefined && typeof st.done !== 'boolean') return { error: `${sa}: done must be true or false` };
114
+ steps[k].push({ t: clip(st.t, 140), who: st.who, done: st.done === true });
115
+ }
116
+ }
117
+ f.steps = steps;
118
+ }
119
+ if (e.initiative !== undefined && e.initiative !== null) {
120
+ const ini = e.initiative;
121
+ if (typeof ini !== 'object' || Array.isArray(ini) || typeof ini.id !== 'string' || !INI_RE.test(ini.id)) return { error: `${at}: initiative must be {id, why} with an initiative id` };
122
+ const err = optText(ini.why, 240, `${at}: initiative.why`); if (err) return { error: err };
123
+ f.initiative = { id: ini.id, why: clip(ini.why, 240) };
124
+ }
125
+ if (e.links !== undefined && e.links !== null) {
126
+ if (!Array.isArray(e.links)) return { error: `${at}: links must be a list` };
127
+ if (e.links.length > 5) return { error: `${at}: links has ${e.links.length} entries — at most 5` };
128
+ f.links = [];
129
+ for (const [j, l] of e.links.entries()) {
130
+ if (!l || typeof l !== 'object' || Array.isArray(l)) return { error: `${at}: link ${j + 1} is not an object` };
131
+ const err = optText(l.label, 60, `${at}: link ${j + 1} label`) || (isUrl(l.url) ? null : `${at}: link ${j + 1} url must be an http(s) link of at most 500 characters`);
132
+ if (err) return { error: err };
133
+ f.links.push({ label: clip(l.label, 60), url: l.url });
134
+ }
135
+ }
136
+ if (e.notes !== undefined && e.notes !== null) {
137
+ const n = e.notes;
138
+ if (typeof n !== 'object' || Array.isArray(n)) return { error: `${at}: notes must be {wispr, mail}` };
139
+ for (const k of Object.keys(n)) if (k !== 'wispr' && k !== 'mail') return { error: `${at}: notes has "${k}" — only wispr and mail` };
140
+ const notes = {};
141
+ if (n.wispr !== undefined && n.wispr !== null) {
142
+ const w = n.wispr; const wa = `${at}: notes.wispr`;
143
+ if (typeof w !== 'object' || Array.isArray(w)) return { error: `${wa} is not an object` };
144
+ if (sources.wispr !== 'read') return { error: `${wa} was given but wispr is not reported as read` };
145
+ const err = optLink(w.link, `${wa} link`) || optText(w.summary, 480, `${wa} summary`) || optLines(w.decisions, 5, 140, `${wa} decisions`) || optLines(w.actions, 5, 140, `${wa} actions`);
146
+ if (err) return { error: err };
147
+ notes.wispr = { link: w.link || null, summary: clip(w.summary, 480), decisions: lines(w.decisions, 140), actions: lines(w.actions, 140) };
148
+ }
149
+ if (n.mail !== undefined && n.mail !== null) {
150
+ const m = n.mail; const ma = `${at}: notes.mail`;
151
+ if (typeof m !== 'object' || Array.isArray(m)) return { error: `${ma} is not an object` };
152
+ if (sources.mail !== 'read') return { error: `${ma} was given but mail is not reported as read` };
153
+ const err = optText(m.subject, 140, `${ma} subject`) || optText(m.from, 120, `${ma} from`) || optText(m.gist, 480, `${ma} gist`) || optLink(m.link, `${ma} link`);
154
+ if (err) return { error: err };
155
+ notes.mail = { subject: clip(m.subject, 140), from: clip(m.from, 120), gist: clip(m.gist, 480), link: m.link || null };
156
+ }
157
+ f.notes = notes;
158
+ }
159
+ return { fields: f };
160
+ }
161
+
162
+ /* The context of one item (G18). Returns {error} or {fields} with only what was given, and the kind (always set). */
163
+ function itemExtras(x, at) {
164
+ const f = {};
165
+ 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`)
166
+ || optText(x.ask, 240, `${at}: ask`) || optText(x.how, 480, `${at}: how`);
167
+ if (err) return { error: err };
168
+ if (x.link) f.link = x.link;
169
+ 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);
170
+ if (x.reply_needed !== undefined && x.reply_needed !== null) {
171
+ if (typeof x.reply_needed !== 'boolean') return { error: `${at}: reply_needed must be true or false` };
172
+ f.reply_needed = x.reply_needed;
173
+ }
174
+ // The kind says who finishes the job. The PM's own item is always `you`: an agent cannot approve or auto-run it.
175
+ const kind = x.kind === undefined || x.kind === null ? (x.owner === 'pm' ? 'you' : 'approve') : x.kind;
176
+ if (!KINDS.includes(kind)) return { error: `${at}: kind must be one of ${KINDS.join(', ')}` };
177
+ if (x.owner === 'pm' && kind !== 'you') return { error: `${at}: an item the PM owns has kind "you" — "${kind}" is for an item the agent owns` };
178
+ f.kind = kind;
179
+ return { fields: f };
180
+ }
181
+
182
+ /* The fixed order of items: higher confidence first, then PM-owned before agent-owned, then by title. */
183
+ function sortItems(items) {
184
+ return items.sort((a, b) => b.confidence - a.confidence || (a.owner === b.owner ? 0 : a.owner === 'pm' ? -1 : 1) || a.title.localeCompare(b.title));
185
+ }
43
186
 
44
187
  /** @returns {{ok:true, brief:object}|{ok:false, error:string}} */
45
188
  function validateBrief(raw, ctx = {}) {
@@ -82,8 +225,15 @@ function validateBrief(raw, ctx = {}) {
82
225
  if (e.with !== undefined && tooLong(e.with, 120)) return { ok: false, error: `calendar event ${i + 1}: "with" is over 120 characters` };
83
226
  const gErr = badGoal(e.goal, `calendar event ${i + 1}`);
84
227
  if (gErr) return { ok: false, error: gErr };
85
- calendar.push({ start: e.start, end: e.end || null, title: clip(e.title, 120), with: e.with ? clip(e.with, 120) : null, ...(e.goal ? { goal: e.goal } : {}) });
228
+ if (e.id !== undefined) return { ok: false, error: `calendar event ${i + 1}: id is derived by the workspace — do not give one` };
229
+ const ex = eventExtras(e, `calendar event ${i + 1}`, sources);
230
+ if (ex.error) return { ok: false, error: ex.error };
231
+ const title = clip(e.title, 120);
232
+ // The id is derived from the day, the start and the title, so a check that reports the same meeting again updates it.
233
+ calendar.push({ id: sha12(`${date}|${e.start}|${title}`), start: e.start, end: e.end || null, title, with: e.with ? clip(e.with, 120) : null,
234
+ ...(e.goal ? { goal: e.goal } : {}), ...ex.fields });
86
235
  }
236
+ if (new Set(calendar.map((e) => e.id)).size !== calendar.length) return { ok: false, error: 'two calendar events have the same start and title' };
87
237
  calendar.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
88
238
 
89
239
  const it = raw.items === undefined ? [] : raw.items;
@@ -107,25 +257,111 @@ function validateBrief(raw, ctx = {}) {
107
257
  const gErr = badGoal(x.goal, at);
108
258
  if (gErr) return { ok: false, error: gErr };
109
259
  if (x.mention !== undefined && typeof x.mention !== 'boolean') return { ok: false, error: `${at}: mention must be true or false` };
260
+ const ex = itemExtras(x, at);
261
+ if (ex.error) return { ok: false, error: ex.error };
110
262
  const title = clip(x.title, 140);
111
263
  items.push({
112
- id: crypto.createHash('sha1').update(`${date}|${x.source}|${title}`).digest('hex').slice(0, 12),
264
+ id: sha12(`${date}|${x.source}|${title}`),
113
265
  title, why_now: clip(x.why_now, 240), evidence: clip(x.evidence, 240), source: x.source, initiative_id: x.initiative_id || null,
114
266
  owner: x.owner, effort: x.effort, confidence: x.confidence, worth_asking: x.confidence < WORTH_ASKING,
115
- ...(x.goal ? { goal: x.goal } : {}), ...(x.mention ? { mention: true } : {}),
267
+ ...(x.goal ? { goal: x.goal } : {}), ...(x.mention ? { mention: true } : {}), ...ex.fields,
116
268
  });
117
269
  }
118
- items.sort((a, b) => b.confidence - a.confidence || (a.owner === b.owner ? 0 : a.owner === 'pm' ? -1 : 1) || a.title.localeCompare(b.title));
270
+ sortItems(items);
119
271
  const ids = new Set(items.map((x) => x.id));
120
272
  if (ids.size !== items.length) return { ok: false, error: 'two items have the same source and title' };
121
273
 
274
+ const dg = raw.digests === undefined || raw.digests === null ? [] : raw.digests;
275
+ if (!Array.isArray(dg)) return { ok: false, error: 'digests must be a list' };
276
+ if (dg.length > MAX_DIGESTS) return { ok: false, error: `digests has ${dg.length} entries — at most ${MAX_DIGESTS}` };
277
+ const digests = [];
278
+ for (const [i, d] of dg.entries()) {
279
+ const at = `digest ${i + 1}`;
280
+ if (!d || typeof d !== 'object' || Array.isArray(d)) return { ok: false, error: `${at} is not an object` };
281
+ if (!DIGEST_SOURCES.includes(d.source)) return { ok: false, error: `${at}: source must be one of ${DIGEST_SOURCES.join(', ')}` };
282
+ if (sources[d.source] !== 'read') return { ok: false, error: `${at}: source "${d.source}" is not reported as read, so it cannot have a digest` };
283
+ if (typeof d.name !== 'string' || !d.name.trim()) return { ok: false, error: `${at} needs a name (the channel, project or space)` };
284
+ const err = optText(d.name, 120, `${at}: name`) || optLink(d.link, `${at}: link`) || optLines(d.decisions, 5, 140, `${at}: decisions`)
285
+ || optLines(d.open, 5, 140, `${at}: open`) || optLines(d.goals, 5, 140, `${at}: goals`);
286
+ if (err) return { ok: false, error: err };
287
+ const name = clip(d.name, 120);
288
+ if (digests.some((y) => y.source === d.source && y.name === name)) return { ok: false, error: `${at}: ${d.source} ${name} has two digests` };
289
+ digests.push({ source: d.source, name, link: d.link || null, decisions: lines(d.decisions, 140), open: lines(d.open, 140), goals: lines(d.goals, 140) });
290
+ }
291
+
292
+ // `resolved` is read by a merge only: the ids of stored items a check found done or answered.
293
+ const rs = raw.resolved === undefined || raw.resolved === null ? [] : raw.resolved;
294
+ if (!Array.isArray(rs) || rs.length > MAX_RESOLVED || rs.some((x) => typeof x !== 'string' || !ITEM_ID_RE.test(x))) return { ok: false, error: `resolved must be a list of at most ${MAX_RESOLVED} item ids` };
295
+
122
296
  let judgment = { used: false };
123
297
  if (raw.judgment !== undefined) {
124
298
  if (!raw.judgment || typeof raw.judgment !== 'object' || typeof raw.judgment.used !== 'boolean') return { ok: false, error: 'judgment must be {"used": true|false}' };
125
299
  if (raw.judgment.used && !settings.briefScoring) return { ok: false, error: 'typed scoring is not switched on in the settings, so a brief cannot say it used it' };
126
300
  judgment = { used: raw.judgment.used };
127
301
  }
128
- return { ok: true, brief: { date, generated_at: new Date(now).toISOString(), sources, calendar, priorities: items.filter((x) => !x.worth_asking), worth_asking: items.filter((x) => x.worth_asking), judgment } };
302
+ return { ok: true, brief: { date, generated_at: new Date(now).toISOString(), sources, calendar, priorities: items.filter((x) => !x.worth_asking), worth_asking: items.filter((x) => x.worth_asking), judgment,
303
+ digests, resolved: [...new Set(rs)] } };
304
+ }
305
+
306
+ /**
307
+ * Merge a checked brief into the stored brief of the same day (G22). Pure: the caller validated `incoming` with
308
+ * validateBrief and writes what this returns. With no stored brief, the incoming one is stored as it is.
309
+ * items the stored ones not in incoming.resolved, plus the incoming ones whose id is new (marked new, first_seen now).
310
+ * An incoming item with a stored id replaces it and keeps its first_seen and new. More than 30 is refused.
311
+ * events by id: the fields given overwrite, notes merge by kind (wispr, mail), new events are added.
312
+ * digests replaced by (source, name). sources 'read' wins, otherwise the stored state is kept.
313
+ * @returns {{ok:true, brief:object, added:number, removed:number}|{ok:false, error:string}}
314
+ */
315
+ function mergeBrief(stored, incoming, { now = Date.now() } = {}) {
316
+ if (!incoming || typeof incoming !== 'object') return { ok: false, error: 'nothing to merge' };
317
+ const inItems = [...(incoming.priorities || []), ...(incoming.worth_asking || [])];
318
+ if (!stored || typeof stored !== 'object') return { ok: true, brief: { ...incoming, digests: incoming.digests || [], resolved: [] }, added: inItems.length, removed: 0 };
319
+ if (stored.date !== incoming.date) return { ok: false, error: `a merge is for the stored brief of the same day — the stored brief is for ${stored.date}` };
320
+ const at = new Date(now).toISOString();
321
+ const gone = new Set(incoming.resolved || []);
322
+ const was = [...(stored.priorities || []), ...(stored.worth_asking || [])];
323
+ const kept = was.filter((x) => !gone.has(x.id));
324
+ const byId = new Map(kept.map((x) => [x.id, x]));
325
+ let added = 0;
326
+ for (const x of inItems) {
327
+ if (gone.has(x.id)) continue;
328
+ const old = byId.get(x.id);
329
+ if (old) byId.set(x.id, { ...x, ...(old.first_seen ? { first_seen: old.first_seen } : {}), ...(old.new ? { new: true } : {}) });
330
+ else { byId.set(x.id, { ...x, new: true, first_seen: at }); added++; }
331
+ }
332
+ const items = sortItems([...byId.values()]);
333
+ if (items.length > MAX_ITEMS) return { ok: false, error: `the brief would hold ${items.length} items — at most ${MAX_ITEMS}` };
334
+
335
+ const evs = new Map((stored.calendar || []).map((e) => [e.id, { ...e }]));
336
+ for (const e of incoming.calendar || []) {
337
+ const old = evs.get(e.id);
338
+ if (!old) { evs.set(e.id, e); continue; }
339
+ const next = { ...old };
340
+ for (const [k, v] of Object.entries(e)) if (v !== null && v !== undefined && k !== 'notes') next[k] = v;
341
+ if (e.notes) next.notes = { ...(old.notes || {}), ...e.notes };
342
+ evs.set(e.id, next);
343
+ }
344
+ const calendar = [...evs.values()].sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
345
+ if (calendar.length > MAX_EVENTS) return { ok: false, error: `the brief would hold ${calendar.length} events — at most ${MAX_EVENTS}` };
346
+
347
+ const dg = new Map((stored.digests || []).map((d) => [d.source + '|' + d.name, d]));
348
+ for (const d of incoming.digests || []) dg.set(d.source + '|' + d.name, d);
349
+ if (dg.size > MAX_DIGESTS) return { ok: false, error: `the brief would hold ${dg.size} digests — at most ${MAX_DIGESTS}` };
350
+
351
+ const sources = {};
352
+ for (const k of SOURCES) {
353
+ const a = (stored.sources || {})[k]; const b = (incoming.sources || {})[k];
354
+ // A check reports only the sources it read: a source it did not list keeps the state of the stored brief.
355
+ sources[k] = a === 'read' || b === 'read' ? 'read' : (a || b || 'not-reached');
356
+ }
357
+ const taken = was.filter((x) => gone.has(x.id)).map((x) => x.id);
358
+ return { ok: true, added, removed: taken.length, brief: {
359
+ date: stored.date, generated_at: stored.generated_at || at, updated_at: at, sources, calendar,
360
+ priorities: items.filter((x) => !x.worth_asking), worth_asking: items.filter((x) => x.worth_asking),
361
+ judgment: { used: !!((stored.judgment && stored.judgment.used) || (incoming.judgment && incoming.judgment.used)) },
362
+ digests: [...dg.values()], resolved: [...new Set([...(stored.resolved || []), ...taken])].slice(-MAX_RESOLVED),
363
+ } };
129
364
  }
130
365
 
131
- module.exports = { SOURCES, ITEM_SOURCES, SOURCE_STATES, OWNERS, EFFORTS, WORTH_ASKING, MAX_ITEMS, MAX_EVENTS, validateBrief, greeting, localDate };
366
+ module.exports = { SOURCES, ITEM_SOURCES, SOURCE_STATES, OWNERS, EFFORTS, KINDS, DIGEST_SOURCES, WORTH_ASKING, MAX_ITEMS, MAX_EVENTS, MAX_DIGESTS,
367
+ isUrl, validateBrief, mergeBrief, greeting, localDate };