create-pmos 0.39.0 → 0.40.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.39.0",
3
+ "version": "0.40.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@063ee3c
1
+ kernel-source: pmos@cd7bc4a
2
2
  assembled: 2026-10-03
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-03T07:05:43Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-03T10:44:27Z"}
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-03T07:05:43Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-03T10:44:27Z"}
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-03T07:05:43Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-03T10:44:27Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -74,6 +74,14 @@ Only the step the ladder has reached can be prepared; the build is refused until
74
74
 
75
75
  After the session ends, `GET /api/next-step/summary` returns the files the branch changed against trunk, the end of the conversation and the judgments, checked; `POST /api/next-step/review` records the PM's verdict (`accept` or `send-back` with a note). Runs and verdicts are `next-step-runs.jsonl` and `step-reviews.jsonl` in the runtime state directory. A verdict never changes the initiative's status.
76
76
 
77
+ ## Today: what is waiting for you (`proactive-engine`, slice D1)
78
+
79
+ `lib/proactive.js` is a pure core: it reads the records the workspace already derives and finds five conditions: a need waiting on the PM, an initiative returned at acceptance, an ended next-step session with no review, a scheduled task whose last run failed, and an active initiative with no agent run for the stalled threshold (5 days by default). The ranking is fixed and stated in the guide. `GET /api/proactive/today` computes the list on each request. A scan (`POST /api/proactive/scan`, and a 10-minute automatic tick inside the existing 60-second interval) appends new items once a day to `signals.jsonl`. Feedback (`useful`, `not-useful`, `wrong-priority`, `missed` with a note, and the events `opened`, `dismissed`) is appended to `proactive-feedback.jsonl`; a dismissed item is hidden for 7 days. Settings are in `proactive-settings.json` (engine on or off, one trigger off, the stalled threshold, quiet hours, pause learning; the last has no effect yet because nothing learns from the verdicts in this slice). The same actions are in `pmos proactive today|scan|settings|feedback` (`lib/proactive-cli.js`), a thin client of those routes. The list is drawn in the navigation column by the `today` drawer item. It runs only while the server runs, reads no mail or calendar, and starts no session, sends nothing and changes no record of an initiative (`proactive.test.js` E25-E26 read the source for that). Tests: `proactive.test.js` (pure core, routes, CLI) and `pm-r22-test.js` (the page). The guide page is `guide/proactive-engine.md`.
80
+
81
+ ## The morning brief (`proactive-engine`, slice D2)
82
+
83
+ The top of Today is a brief: a greeting, the day's calendar, ranked priorities and a worth-asking list. The server does not build it. The built-in skill `skills/morning-brief.skill` runs in an agent session, reads the sources the settings allow (calendar, wispr, mail, jira, confluence; mail is off by default) and the derived list (`pmos proactive today`), and submits one brief with `pmos proactive brief submit --file`. `POST /api/proactive/brief` checks it with `lib/brief.js` and stores `briefs/<date>.json` in the runtime state folder, replacing any earlier brief for the same day; `GET` returns the newest one with a `stale` flag when its date is not today. The check refuses, with the reason and without storing, a wrong shape, an unknown source, a confidence outside 0 to 1, more than 12 items or 20 events, an over-long field, an item that rests on a source not reported as read, a source reported as read that the settings switched off, and a claim of typed scoring when that setting is off. The bucket is derived: under 0.5 confidence is "worth asking". The order is fixed: confidence, then PM-owned, then title. `POST /api/schedules/seed` with `set: "brief"` (one press in Today, or `pmos proactive brief schedule`) makes the daily 06:45 schedule once; as with every schedule, one made after its time of day runs once within a minute. The routes make no model or network call and write only the brief file. Typed scoring is a standing opt-in setting that is off by default. Tests: `proactive.test.js` G, H, I and `pm-r22-test.js` B1-B11. The guide page is `guide/proactive-engine.md`.
84
+
77
85
  ## Scheduled tasks from the terminal (`schedule-cli`)
78
86
 
79
87
  `pmos schedule` and `pmos skill` talk to the running workspace server, so the terminal and the workspace share one schedule store and one validator. Every change is saved the moment the command succeeds. A refused value names what is allowed and changes nothing. With no server running the command says so and exits 1.
@@ -127,7 +135,7 @@ The three-column layout is retired. The surface is now **four collapsible panels
127
135
  **Fixture mode.** `?fixture=proto` renders the whole surface from a bundled seed file, with the server unable to read any repository — that is how the design comparison is run (`npm run test:parity`), and it is never the default.
128
136
 
129
137
  ## Artifact links are declarations, not integrations
130
- The PMOS server makes no outbound request and holds no credential, with one named exception ([D79](/okf/products/pmos/adr/d79-typesafe-validation-egress.md)): when you save a TypeSafe key and turn validation on for a repository, the **validate** page sends that repository's document text to `api.typesafe.ai`, from one module, only after you confirm the plan. With no key, nothing is sent. The key stays on this machine, is never shown again, and is never given to a terminal. The workspace makes no request you did not ask for. Its git network verbs are named and press-only: `Sync` runs `fetch`, a fast-forward or an own-commit rebase, a commit of record paths, and `push` — only when you press it, never on a timer or on load, never forced ([D70](/okf/products/pmos/adr/d70-stopped-initiatives-and-sync.md)). A web page opens in a tab only when you type or click its address, and a restored web tab loads only when you press "load this page" ([D69](/okf/products/pmos/adr/d69-web-tab.md)). Linking an artifact to an external destination therefore records a **declaration** — `{artifact_path, kind, target}` appended to `pmos/state/artifact-links.jsonl`. The sync panel renders as designed, and with no connector configured its summary line says exactly that: *no connector configured — nothing was read, and nothing will be written*, with the approval buttons disabled. A difference summary it never read would be the watermelon this repo has a word for.
138
+ The PMOS server makes no outbound request and holds no credential, with one named exception ([D79](/okf/products/pmos/adr/d79-typesafe-validation-egress.md)): when you save a TypeSafe key and turn validation on for a repository, the **validate** page sends that repository's document text to `api.typesafe.ai`, from one module, only after you confirm the plan. With no key, nothing is sent. The key stays on this machine, is never shown again, and is never given to a terminal. The consistency checks at the top of the **validate** page are local: they read files and git and send nothing. Automatic checking is off until you switch it on for a repository, and then the workspace appends problems to that repository's `claims.jsonl`. The workspace makes no request you did not ask for. Its git network verbs are named and press-only: `Sync` runs `fetch`, a fast-forward or an own-commit rebase, a commit of record paths, and `push` — only when you press it, never on a timer or on load, never forced ([D70](/okf/products/pmos/adr/d70-stopped-initiatives-and-sync.md)). A web page opens in a tab only when you type or click its address, and a restored web tab loads only when you press "load this page" ([D69](/okf/products/pmos/adr/d69-web-tab.md)). Linking an artifact to an external destination therefore records a **declaration** — `{artifact_path, kind, target}` appended to `pmos/state/artifact-links.jsonl`. The sync panel renders as designed, and with no connector configured its summary line says exactly that: *no connector configured — nothing was read, and nothing will be written*, with the approval buttons disabled. A difference summary it never read would be the watermelon this repo has a word for.
131
139
 
132
140
  ## The gates are real, never automatic (R5)
133
141
  `approve` / `return` / `accept` write the PMOS file-mode vocabulary to `acceptances.jsonl` and clear the matching needs entry — only ever on your click.
@@ -396,6 +396,12 @@ function doctor() {
396
396
  code = await require('../lib/schedule-cli').run(process.argv.slice(2), {
397
397
  say, call: async (m, p, b) => (port ? api(port, m, p, b) : null) });
398
398
  }
399
+ else if (cmd === 'proactive') {
400
+ const live = await discover();
401
+ const port = live.length ? live[0].port : null;
402
+ code = await require('../lib/proactive-cli').run(process.argv.slice(2), {
403
+ say, call: async (m, p, b) => (port ? api(port, m, p, b) : null) });
404
+ }
399
405
  else if (cmd === 'upgrade') {
400
406
  // Escape hatch: pull the latest bundled app and force-reinstall over this global copy. Delegates
401
407
  // to the scaffolder (which owns the install path) rather than reinstalling itself in place.
@@ -407,11 +413,12 @@ function doctor() {
407
413
  if (r.error) { say('✗ could not run ' + npx + ' — ' + r.error.message); code = 1; }
408
414
  else code = r.status === null ? 1 : r.status;
409
415
  } else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
410
- say('usage: pmos [<path>|status|ls|stop|doctor|upgrade|schedule|skill]');
416
+ say('usage: pmos [<path>|status|ls|stop|doctor|upgrade|schedule|skill|proactive]');
411
417
  say(' pmos open the current repo as a workspace (starts the server if needed)');
412
418
  say(' pmos <path> open another repo as a workspace');
413
419
  say(' pmos schedule … create, edit and run scheduled tasks (pmos schedule --help)');
414
420
  say(' pmos skill … list, read, create and edit skills');
421
+ say(' pmos proactive … what is waiting for you, feedback, settings (pmos proactive --help)');
415
422
  say(' pmos upgrade reinstall the workspace app from the latest published create-pmos');
416
423
  code = 0;
417
424
  } else if (cmd === 'up' || cmd === 'start' || cmd === 'open') {
@@ -20,11 +20,12 @@ The workspace has three columns. [Layout](layout.md) explains where things open.
20
20
 
21
21
  | Item | What it opens |
22
22
  |---|---|
23
+ | today | What is waiting for you now, with your feedback and the settings of the proactive engine. |
23
24
  | memory | What the repository remembers: decisions and notes kept in its memory ledger. |
24
25
  | knowledge | The decisions, concepts and skills of the repository. |
25
26
  | portfolio | Every initiative of every repository, grouped by domain. |
26
27
  | guide | This guide. |
27
- | validate | Checks one repository's documents with TypeSafe. It sends text only after you turn it on. |
28
+ | validate | Checks that one repository agrees with itself, on your machine. It can also check with TypeSafe, which sends text only after you turn it on. |
28
29
  | focus | What is waiting on you, across all repositories. |
29
30
  | skills | Built-in and user skills. A built-in is read-only. |
30
31
  | scheduled tasks | Skills that run on a schedule. |
@@ -35,4 +36,5 @@ The workspace has three columns. [Layout](layout.md) explains where things open.
35
36
  - [Statuses](statuses.md): what each status means, where it comes from, and what you can change.
36
37
  - [Initiatives and next steps](initiatives-and-next-steps.md): the detail, the next steps, and the artifacts.
37
38
  - [Scheduled tasks and the command line](scheduled-tasks-and-cli.md): how to make, edit and run scheduled tasks.
38
- - [Validate a repository with TypeSafe](validate-with-typesafe.md): what leaves your machine, how to run a validation, and how to read the findings.
39
+ - [Validate a repository](validate-with-typesafe.md): what each kind of document is compared with, how to read the verdict, and what leaves your machine.
40
+ - [Today and the proactive engine](proactive-engine.md): what Today finds, what you can do with an item, the settings, the commands and the limits.
@@ -24,6 +24,19 @@ The second column shows one item in full. Choose an initiative, a skill, a sched
24
24
 
25
25
  The third column holds your terminals, documents, conversations and guide pages. Folding or resizing the other columns does not touch your terminals.
26
26
 
27
+ ## Open a file
28
+
29
+ Press a file anywhere in the workspace to open it in a tab. Any text file opens, whatever its extension.
30
+
31
+ - **Markdown** opens as a document. You can edit it and save it.
32
+ - **JSON** opens pretty-printed. If the file is not valid JSON, the tab shows the text as written and says why.
33
+ - **JSONL** opens as numbered records, one for each line. The tab marks a line that is not valid JSON.
34
+ - **Other text** opens as numbered source lines. Spreadsheets, PDFs and images open in their own views.
35
+
36
+ A tab for a file that is not markdown is read-only. It has no edit, diff or save control, because the workspace saves only markdown.
37
+
38
+ If a file cannot be shown, the tab says why. The reasons are: the file is binary, it is larger than 2 MB, it is not valid text, or it is outside the workspace.
39
+
27
40
  ## The rule for where things open
28
41
 
29
42
  A list or a directory opens in the navigation column, under the repository list. A detail opens in the detail column. A file-like row opens in a tab, as any document does.
@@ -36,6 +49,7 @@ A list or a directory opens in the navigation column, under the repository list.
36
49
  | Portfolio | list | navigation |
37
50
  | Guide | list | navigation |
38
51
  | Validate | list | navigation |
52
+ | Today | list | navigation |
39
53
  | Focus | list | navigation |
40
54
  | Skills | list | navigation |
41
55
  | Scheduled tasks | list | navigation |
@@ -44,7 +58,7 @@ What a row in a list opens:
44
58
 
45
59
  - A row in Initiatives, Portfolio or Focus opens the initiative in the detail column.
46
60
  - A row in Skills, Knowledge, Guide or Validate opens the file in a tab, beside your terminals.
47
- - Scheduled tasks and Memory are used inside the list. They have no separate detail.
61
+ - Scheduled tasks, Memory and Today are used inside the list. They have no separate detail.
48
62
 
49
63
  ## Folding
50
64
 
@@ -0,0 +1,75 @@
1
+ ---
2
+ type: Guide
3
+ title: "Guide: Today and the proactive engine"
4
+ timestamp: 2026-10-03
5
+ ---
6
+
7
+ # Guide: Today and the proactive engine
8
+
9
+ The today item shows what is waiting for you now. The workspace works it out by itself from the records of your repositories. You do not ask for it.
10
+
11
+ ## The morning brief
12
+
13
+ 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.
14
+
15
+ - 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.
16
+ - Each source is a setting: calendar, wispr, mail, jira and confluence. Mail is off by default and the skill reads only subjects and senders, never bodies. A source that is off cannot be reported as read.
17
+ - The brief names the day it is for. If that is not today, Today says it is out of date.
18
+ - 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.
19
+ - The order is fixed: higher confidence first, then your items before the agent's, then by title.
20
+ - Each item takes the same feedback as a derived item: useful, not useful and wrong priority.
21
+ - 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.
22
+ - 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.
23
+ - The brief is stored in the workspace state folder on this computer. It is not committed.
24
+
25
+ ## What it shows
26
+
27
+ Today scans for five conditions. Each one becomes one item with the reason and the evidence.
28
+
29
+ | Trigger | What it finds |
30
+ |---|---|
31
+ | needs | A need that waits on you. |
32
+ | returned | An initiative the PM returned at acceptance. |
33
+ | review | An agent session that ended and has no review yet. |
34
+ | schedule | A scheduled task whose last run failed. |
35
+ | stalled | An active initiative with no run for the stalled threshold. The default is 5 days. |
36
+
37
+ The order is fixed and you can read it: what blocks on you first, then an unreviewed session, then a failed schedule, then a stalled initiative. Inside one kind, the oldest item is first.
38
+
39
+ ## What you can do with an item
40
+
41
+ - open takes you to the initiative or the schedule.
42
+ - useful, not useful and wrong priority record your verdict.
43
+ - dismiss 7 days hides the item for 7 days.
44
+ - something was missed records a note about what Today should have shown. It needs a note.
45
+
46
+ A verdict never changes a status, a lifecycle record or an acceptance. In this release a verdict is only recorded. Nothing uses it to change the order yet.
47
+
48
+ ## Settings
49
+
50
+ | Setting | Meaning |
51
+ |---|---|
52
+ | engine on | Off stops automatic scans. The scan now button still works. |
53
+ | watch: needs, returned, review, schedule, stalled | Turn one trigger off. |
54
+ | brief sources | One switch for each of calendar, wispr, mail, jira and confluence. |
55
+ | typed scoring | Off by default. On lets the skill send titles and metadata to the Typesafe service. |
56
+ | name | Used in the greeting. |
57
+ | stalled after N days | The stalled threshold, from 1 to 90 days. |
58
+ | quiet hours | A start and an end time. No automatic scan runs between them. |
59
+ | pause learning | Saved for later. Nothing learns from your verdicts in this release, so it changes nothing today. |
60
+
61
+ ## The commands
62
+
63
+ - `pmos proactive today` lists the items.
64
+ - `pmos proactive scan` scans now.
65
+ - `pmos proactive settings` shows the settings. Add `--enabled`, `--trigger NAME=on|off`, `--stalled-days`, `--quiet HH:MM-HH:MM|none`, `--pause-learning`, `--brief-source NAME=on|off`, `--brief-scoring on|off` or `--name` to change one.
66
+ - `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.
67
+ - `pmos proactive feedback <id> useful|not-useful|wrong-priority|opened|dismissed` records feedback. Use `--missed "text"` for a missed item.
68
+
69
+ ## Limits
70
+
71
+ - 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.
72
+ - The items from the records come from this computer. The workspace itself does not read your mail or your calendar. Only the morning-brief session does, and only the sources you switched on.
73
+ - It does not start a session, push, send, post, accept or merge. It only shows items and records your feedback.
74
+ - An unreadable repository is listed as not read. An empty list is not proof that nothing waits there.
75
+ - The records stay in the workspace state folder on this computer. They are not committed.
@@ -1,12 +1,45 @@
1
1
  ---
2
2
  type: Guide
3
- title: "Guide: validate a repository with TypeSafe"
3
+ title: "Guide: validate a repository"
4
4
  timestamp: 2026-10-03
5
5
  ---
6
6
 
7
- # Guide: validate a repository with TypeSafe
7
+ # Guide: validate a repository
8
8
 
9
- The validate item checks the documents of one repository. It looks for documents that are unreliable, that disagree with each other, or that are out of date. It uses TypeSafe's Jev model for the checks that need judgement.
9
+ The validate item checks the documents of one repository. It has two parts. The first part checks that the repository agrees with itself. It runs on your machine and sends nothing. The second part uses TypeSafe's Jev model for checks that need judgement. It sends text only after you turn it on.
10
+
11
+ ## Check that the repository agrees with itself
12
+
13
+ The top of the validate page shows a verdict: *Consistent*, a number of problems, or *Not checked yet*. Press **check now** to get one.
14
+
15
+ The workspace first decides what kind of document each file is. It uses the `type` in the file, an initiative record that names the file, the file name, or the headings. Then it compares the file only with what that kind of document must agree with:
16
+
17
+ | Kind of document | Compared with |
18
+ |---|---|
19
+ | Initiative record | The PRD, rubric and explainer it points to. Each must exist and name the initiative. |
20
+ | PRD | Its initiative record, its rubric, and the decisions it cites. |
21
+ | Rubric | Its PRD. The dimension weights must sum to 1.00. |
22
+ | Story | Its PRD. Each requirement the story cites must exist in the PRD. |
23
+ | Decision record | The decisions file. The decision must be recorded there. |
24
+ | Any other markdown file | Its own links and dates only. |
25
+ | JSON file | Its own syntax only. |
26
+
27
+ A file that nothing declares a kind for is **not classified**. The workspace never compares it with another file.
28
+
29
+ Each document row shows what it was compared with. Each finding is one sentence. It names the documents and says what the source says. The groups are:
30
+
31
+ - **Problems.** A statement is contradicted by its source. For example, a link points to a file that does not exist.
32
+ - **Stale.** A source changed after the document did, or the date in the document has passed. A source must be at least one hour newer than the document to count.
33
+ - **Worth a look.** The structure cannot decide. For example, a PRD does not name the rubric that names it.
34
+ - **Checked and fine**, and **not classified.** Open a group to see its documents.
35
+
36
+ The record of problems is the file `claims.jsonl` in the runs folder of a PMOS repository. Press its name to read it in a tab. It lists every claim that is not verified. It does not list claims that verified the first time.
37
+
38
+ **Automatic checking** is off until you turn it on for a repository. When it is on, the workspace looks for changed files about every ten seconds. It waits until the files have stopped changing for about eight seconds. Then it re-checks the changed documents and the documents that cite them. It re-checks no others. The check reads only files that changed. It makes no network request.
39
+
40
+ A verdict of *Consistent* means that no recorded statement contradicts its source. The workspace does not read prose. A sentence that no structure records is not checked.
41
+
42
+ ## Check with TypeSafe
10
43
 
11
44
  ## What leaves your machine
12
45
 
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+ /* brief.js — the morning brief: what a session submits, checked once, and how it is ordered.
3
+ *
4
+ * The workspace server makes no model or network call and reaches no connector. A scheduled skill runs in an agent session,
5
+ * reads the sources the profile declares and the settings allow, and submits ONE brief. This module owns the shape of that
6
+ * brief and the check of it. A brief in any other shape is refused with the reason, never repaired. Every text field has a
7
+ * short limit, so a message body cannot be stored here by accident.
8
+ *
9
+ * validateBrief(raw, {now, settings}) → {ok:true, brief} | {ok:false, error}
10
+ * greeting(now, name) → the one line Today opens with
11
+ * localDate(now) → YYYY-MM-DD on this machine's clock
12
+ *
13
+ * The bucket is derived: an item under WORTH_ASKING confidence is "worth asking", whatever the session said. The order is
14
+ * fixed and stated: higher confidence first, then PM-owned before agent-owned, then by title.
15
+ */
16
+ const crypto = require('crypto');
17
+
18
+ const SOURCES = ['calendar', 'wispr', 'mail', 'jira', 'confluence'];
19
+ const ITEM_SOURCES = [...SOURCES, 'derived'];
20
+ const SOURCE_STATES = ['read', 'not-reached', 'off'];
21
+ const OWNERS = ['pm', 'agent'];
22
+ const EFFORTS = ['minutes', 'an-hour', 'half-day', 'day'];
23
+ const WORTH_ASKING = 0.5;
24
+ const MAX_ITEMS = 12;
25
+ const MAX_EVENTS = 20;
26
+ const INI_RE = /^[a-z0-9][a-z0-9._-]*$/i;
27
+ const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
28
+
29
+ const pad = (n) => String(n).padStart(2, '0');
30
+ const localDate = (now) => { const d = new Date(now); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; };
31
+ function greeting(now, name) {
32
+ const h = new Date(now).getHours();
33
+ const part = h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
34
+ return part + (name ? ', ' + name : '') + '.';
35
+ }
36
+
37
+ const tooLong = (v, n) => typeof v === 'string' && v.trim().length > n;
38
+
39
+ /** @returns {{ok:true, brief:object}|{ok:false, error:string}} */
40
+ function validateBrief(raw, ctx = {}) {
41
+ const now = ctx.now || Date.now();
42
+ const settings = ctx.settings || {};
43
+ const on = (src) => !settings.briefSources || settings.briefSources[src] !== false;
44
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, error: 'a brief is a JSON object' };
45
+ const date = raw.date === undefined ? localDate(now) : raw.date;
46
+ if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) return { ok: false, error: 'date must be YYYY-MM-DD' };
47
+
48
+ const sources = {};
49
+ const given = raw.sources === undefined ? {} : raw.sources;
50
+ if (!given || typeof given !== 'object' || Array.isArray(given)) return { ok: false, error: `sources must be an object of ${SOURCES.join(', ')} → ${SOURCE_STATES.join(' | ')}` };
51
+ for (const k of Object.keys(given)) if (!SOURCES.includes(k)) return { ok: false, error: `unknown source "${k}" — one of ${SOURCES.join(', ')}` };
52
+ for (const k of SOURCES) {
53
+ const v = given[k] === undefined ? 'not-reached' : given[k];
54
+ if (!SOURCE_STATES.includes(v)) return { ok: false, error: `source "${k}" must be one of ${SOURCE_STATES.join(', ')}` };
55
+ if (!on(k) && v === 'read') return { ok: false, error: `"${k}" is switched off in the settings, so it cannot be reported as read` };
56
+ sources[k] = !on(k) ? 'off' : v;
57
+ }
58
+
59
+ const ev = raw.calendar === undefined ? [] : raw.calendar;
60
+ if (!Array.isArray(ev)) return { ok: false, error: 'calendar must be a list of events' };
61
+ if (ev.length > MAX_EVENTS) return { ok: false, error: `calendar has ${ev.length} events — at most ${MAX_EVENTS}` };
62
+ if (ev.length && sources.calendar !== 'read') return { ok: false, error: 'calendar events were given but calendar is not reported as read' };
63
+ const calendar = [];
64
+ for (const [i, e] of ev.entries()) {
65
+ if (!e || typeof e !== 'object' || Array.isArray(e)) return { ok: false, error: `calendar event ${i + 1} is not an object` };
66
+ if (typeof e.title !== 'string' || !e.title.trim()) return { ok: false, error: `calendar event ${i + 1} needs a title` };
67
+ if (tooLong(e.title, 120)) return { ok: false, error: `calendar event ${i + 1}: title is over 120 characters` };
68
+ if (typeof e.start !== 'string' || Number.isNaN(Date.parse(e.start))) return { ok: false, error: `calendar event ${i + 1}: start must be a date and time` };
69
+ if (e.end !== undefined && (typeof e.end !== 'string' || Number.isNaN(Date.parse(e.end)))) return { ok: false, error: `calendar event ${i + 1}: end must be a date and time` };
70
+ if (e.with !== undefined && tooLong(e.with, 120)) return { ok: false, error: `calendar event ${i + 1}: "with" is over 120 characters` };
71
+ calendar.push({ start: e.start, end: e.end || null, title: clip(e.title, 120), with: e.with ? clip(e.with, 120) : null });
72
+ }
73
+ calendar.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
74
+
75
+ const it = raw.items === undefined ? [] : raw.items;
76
+ if (!Array.isArray(it)) return { ok: false, error: 'items must be a list' };
77
+ if (it.length > MAX_ITEMS) return { ok: false, error: `items has ${it.length} entries — at most ${MAX_ITEMS}` };
78
+ const items = [];
79
+ for (const [i, x] of it.entries()) {
80
+ const at = `item ${i + 1}`;
81
+ if (!x || typeof x !== 'object' || Array.isArray(x)) return { ok: false, error: `${at} is not an object` };
82
+ if (typeof x.title !== 'string' || !x.title.trim()) return { ok: false, error: `${at} needs a title` };
83
+ if (tooLong(x.title, 140)) return { ok: false, error: `${at}: title is over 140 characters` };
84
+ if (typeof x.why_now !== 'string' || !x.why_now.trim()) return { ok: false, error: `${at} needs why_now` };
85
+ if (tooLong(x.why_now, 240)) return { ok: false, error: `${at}: why_now is over 240 characters` };
86
+ if (x.evidence !== undefined && tooLong(x.evidence, 240)) return { ok: false, error: `${at}: evidence is over 240 characters` };
87
+ if (!ITEM_SOURCES.includes(x.source)) return { ok: false, error: `${at}: source must be one of ${ITEM_SOURCES.join(', ')}` };
88
+ if (x.source !== 'derived' && sources[x.source] !== 'read') return { ok: false, error: `${at}: source "${x.source}" is not reported as read, so it cannot support an item` };
89
+ if (!OWNERS.includes(x.owner)) return { ok: false, error: `${at}: owner must be one of ${OWNERS.join(', ')}` };
90
+ if (!EFFORTS.includes(x.effort)) return { ok: false, error: `${at}: effort must be one of ${EFFORTS.join(', ')}` };
91
+ if (typeof x.confidence !== 'number' || !Number.isFinite(x.confidence) || x.confidence < 0 || x.confidence > 1) return { ok: false, error: `${at}: confidence must be a number from 0 to 1` };
92
+ if (x.initiative_id !== undefined && x.initiative_id !== null && (typeof x.initiative_id !== 'string' || !INI_RE.test(x.initiative_id))) return { ok: false, error: `${at}: initiative_id is not an initiative id` };
93
+ const title = clip(x.title, 140);
94
+ items.push({
95
+ id: crypto.createHash('sha1').update(`${date}|${x.source}|${title}`).digest('hex').slice(0, 12),
96
+ title, why_now: clip(x.why_now, 240), evidence: clip(x.evidence, 240), source: x.source, initiative_id: x.initiative_id || null,
97
+ owner: x.owner, effort: x.effort, confidence: x.confidence, worth_asking: x.confidence < WORTH_ASKING,
98
+ });
99
+ }
100
+ items.sort((a, b) => b.confidence - a.confidence || (a.owner === b.owner ? 0 : a.owner === 'pm' ? -1 : 1) || a.title.localeCompare(b.title));
101
+ const ids = new Set(items.map((x) => x.id));
102
+ if (ids.size !== items.length) return { ok: false, error: 'two items have the same source and title' };
103
+
104
+ let judgment = { used: false };
105
+ if (raw.judgment !== undefined) {
106
+ if (!raw.judgment || typeof raw.judgment !== 'object' || typeof raw.judgment.used !== 'boolean') return { ok: false, error: 'judgment must be {"used": true|false}' };
107
+ 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' };
108
+ judgment = { used: raw.judgment.used };
109
+ }
110
+ 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 } };
111
+ }
112
+
113
+ module.exports = { SOURCES, ITEM_SOURCES, SOURCE_STATES, OWNERS, EFFORTS, WORTH_ASKING, MAX_ITEMS, MAX_EVENTS, validateBrief, greeting, localDate };