create-pmos 0.71.0 → 0.72.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.71.0",
3
+ "version": "0.72.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@c87956b
1
+ kernel-source: pmos@87d0b90
2
2
  assembled: 2026-10-07
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-07T04:24:34Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T05:08:49Z"}
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-07T04:24:34Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T05:08:49Z"}
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-07T04:24:34Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-07T05:08:49Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -20,11 +20,13 @@
20
20
  // The budgets start at specimen A's counts. reports: 14 → 15 in Run 6 for Present, the one control the PM asked to keep
21
21
  // with the slides (2026-10-06). Every place +1 for Docs in the top bar (the PM, 2026-10-06, docs-three-way-sync): the
22
22
  // budget is for the whole screen and the bar is on every screen. The calibration stays specimen A's own count: the
23
- // specimen did not change. Every place +1 again for People in the top bar (the PM, 2026-10-06, people-directory).
23
+ // specimen did not change. Every place +1 again for People in the top bar (the PM, 2026-10-06, people-directory), and
24
+ // +1 again for My work beside Agenda (the PM approved that mockup and said build it, 2026-10-07, my-work).
24
25
  const SPECIMEN_A = Object.freeze({ today: 17, initiatives: 24, board: 33, reports: 14 });
25
26
  const BAR_DOCS = 1;
26
27
  const BAR_PEOPLE = 1;
27
- const BAR = BAR_DOCS + BAR_PEOPLE;
28
+ const BAR_MY_WORK = 1;
29
+ const BAR = BAR_DOCS + BAR_PEOPLE + BAR_MY_WORK;
28
30
  // Agenda opens on the month (the PM's Agenda mockup v5, 2026-10-06): the month adds its own controls to Today's budget,
29
31
  // at most six week rows of a week button and seven days, the two month arrows and the five lenses.
30
32
  const AGENDA_MONTH = 6 * 8 + 2 + 5;
@@ -22,6 +22,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
22
22
  | You want | Go to |
23
23
  |---|---|
24
24
  | What is waiting for you now | Agenda |
25
+ | Every Jira ticket and WBS item assigned to you, ranked, to brief an agent | My work, or press `g w` |
25
26
  | The initiatives of a repository, or of every repository | Initiatives. Choose **All repositories** in Repository ▾ for every repository. |
26
27
  | The sessions of a repository, by initiative | The agent dock. ⌘K → **Branches and sessions** lists the branches. |
27
28
  | The items of an initiative | Board, or Breakdown in the initiative detail |
@@ -38,6 +39,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
38
39
 
39
40
  - [Start the workspace without a terminal](launcher.md): the PMOS app, the login item and `pmos launcher`.
40
41
  - [Jira tickets](jira.md): Jira tickets drafted from the PRD, pushed when you select them, and their status.
42
+ - [My work](my-work.md): the work assigned to you, its order, the brief to an agent, and `pmos my-work`.
41
43
  - [People](people.md): the people you work with in a repository, how they get there, and `pmos people`.
42
44
  - [Docs](docs.md): whether each linked document is the same as, ahead of or behind its Google Docs and Confluence copy.
43
45
  - [Layout](layout.md): the frame, the keys and the rule for where a list or a detail opens.
@@ -17,7 +17,7 @@ The top bar has these parts, from left to right:
17
17
  - **Branches and sessions** is in ⌘K. It 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.
18
18
  - **Agenda, Initiatives, Board, Reports, Docs and People** are the six places. Press one to show it. The Agenda count shows how many items need you. Docs shows whether each document is the same as its Google Docs and Confluence copy. People shows the people you work with in the repository ([People](people.md)).
19
19
  - **Search or capture ⌘K** opens the command palette. Type to find an initiative, a repository, an open tab or a command.
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. When two machines add lines to the same ledger (a `.jsonl` file under `pmos/` or `planning/`), Sync keeps the lines from both machines. If Sync stops, the message names the step that stopped. If both machines changed the same lines of another file, the message names the file. Keep the version you want in that file on one machine, sync it, then sync the other machine.
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. When two machines add lines to the same ledger (a `.jsonl` file under `pmos/` or `planning/`), Sync keeps the lines from both machines. If Sync stops, the message names the step that stopped. If both machines changed the same lines of another file, Sync changes nothing and names the files. Then choose **Push this machine’s version** or **Pull the other machine’s version**. Sync keeps that version of those files, keeps all other changes from both machines, and finishes. It never overwrites the remote history.
21
21
  - **Sync on its own.** If you forget to press Sync, the workspace syncs for you. It syncs each repository once when the workspace starts. It also syncs a repository that has records not yet pushed when no record changed for 5 minutes. In a document repository it syncs every changed file on any branch. In a code repository it syncs only on the default branch (for example `main`), and only when every commit not yet pushed changes records and no code. If you close the tab while records are not yet pushed, the browser asks you first. To turn automatic sync off, start the workspace with `PMOS_AUTO_SYNC=0`.
22
22
  - **Settings** opens the settings of the workspace.
23
23
 
@@ -0,0 +1,64 @@
1
+ ---
2
+ type: Guide
3
+ title: "My work: every Jira ticket and WBS item assigned to you, ranked, briefed to an agent, approved in Agenda"
4
+ timestamp: 2026-10-07
5
+ ---
6
+
7
+ # My work
8
+
9
+ Open My work from the top bar, from ⌘K (**My work**), or press `g w`.
10
+
11
+ My work lists every Jira ticket and every breakdown (WBS) item assigned to you, in all your registered repositories. It ranks them, shows what each one asks, and lets you give an agent an instruction for it. The agent's draft then waits for you in Agenda.
12
+
13
+ ## Where the items come from
14
+
15
+ - **Jira tickets.** The workspace does not read Jira. The **my-work-read** skill reads it in an agent session, with your own Atlassian connection. It reads the open issues assigned to you, with their comments and links, and writes a context card for each: who asked, what they ask, a short summary of the thread, the questions still open, and a suggested instruction for an agent. It does not copy the comments. Press **Read again** in My work to run it.
16
+ - **WBS items.** The plan items whose owner is you, in every registered repository. They come from the same data as the Board's Mine filter. My work changes no plan item.
17
+
18
+ The read is kept in your own runtime folder on this machine. It never goes into a repository.
19
+
20
+ ## The order
21
+
22
+ Each item gets a WSJF score: the cost of waiting divided by the size.
23
+
24
+ | Part | How it is counted |
25
+ |---|---|
26
+ | Value | Jira priority: Highest 10, High 8, Medium 5, Low 2. No priority counts as Medium. Add 3 when the item belongs to an initiative, else 1. |
27
+ | Urgency | No due date 1. Overdue 10. Due in 2 days or less 8. Due in 7 days or less 5. Later 3. |
28
+ | Unblocks | 3 for each item it blocks, at most 9. |
29
+ | Size | XS 1, S 2, M 3, L 5, XL 8. No estimate counts as M. |
30
+
31
+ The score is (value + urgency + unblocks) / size. P1 is 12 or more, P2 is 7 or more, P3 is 3 or more, and P4 is the rest. Open **Details** on an item to see each part.
32
+
33
+ ## Brief an agent
34
+
35
+ Being assigned a ticket does not mean an agent must resolve it. Every new item starts in **To brief**.
36
+
37
+ 1. Open the item. Read **What the agent found**.
38
+ 2. Change the instruction. It starts as the agent's suggestion.
39
+ 3. Press **Run agent**. Or press **I'll handle it** to keep the item yourself.
40
+
41
+ Run agent adds one job to today's day run, with your instruction. The job runs like every Agenda job: at most 5 at once, one draft, and nothing is sent. A hand icon marks an item that looks like a decision only you can make. An agent can still prepare it.
42
+
43
+ **Back to brief** returns an item to To brief, unless an agent is working on it.
44
+
45
+ ## Approve in Agenda
46
+
47
+ When the draft is ready, the item moves to **Ready** in My work, and the job waits in Agenda. Approve, revise or retry it there. My work has no approve button.
48
+
49
+ Agenda shows one card, **N items need your brief**, when items wait in To brief. Its button opens My work. The morning brief lists today's top items from My work and does not brief them for you.
50
+
51
+ ## What My work does not do
52
+
53
+ - It does not write to Jira.
54
+ - It does not open a pull request. A job drafts a reply, a comment, a plan or a document.
55
+
56
+ ## Commands
57
+
58
+ | Command | What it does |
59
+ |---|---|
60
+ | `pmos my-work list [--top N] [--json]` | Every open item, in the order My work shows it. |
61
+ | `pmos my-work show <ITEM> [--json]` | One item: what the agent found, the score and the state. ITEM is its key, its id or its number in the list. |
62
+ | `pmos my-work submit --file <read.json>` | The my-work-read skill submits its read. |
63
+
64
+ None of these briefs an agent or takes an item. Do that in My work.
@@ -14,6 +14,7 @@
14
14
  *
15
15
  * newRun(date, now) · readRun(raw, date)
16
16
  * addTasks(run, brief) agent priorities (kind approve|auto) → waiting tasks
17
+ * addBriefed(run, spec) one task the PM briefed in My work (initiative my-work, PRD R5)
17
18
  * toStart(run, limit) the tasks to start now, each with its mode (do | revise | send)
18
19
  * started · deliver · decide · sent · fail · sessionEnded · sessionStalled
19
20
  * stage(run, brief) reading | working | review | done
@@ -111,6 +112,24 @@ function addTasks(run, brief) {
111
112
  return { ok: true, run: next, added };
112
113
  }
113
114
 
115
+ /**
116
+ * One task the PM briefed in My work: kind approve, with the PM's instruction and the item's context card on it, so the
117
+ * day-task session reads the instruction as the ask (PRD R5, R6). `spec` is lib/my-work.js taskSpec. A run opened only
118
+ * for this (no Prepare my day yet) is marked `opened_by: 'my-work'`, so it does not read as a morning read that failed.
119
+ */
120
+ function addBriefed(run, spec) {
121
+ if (!run) return { ok: false, error: 'no day run today' };
122
+ const s = spec && typeof spec === 'object' ? spec : {};
123
+ if (!TASK_RE.test(String(s.id || ''))) return { ok: false, error: 'a briefed task needs an id like t-0123456789ab' };
124
+ if (!text(s.instruction)) return { ok: false, error: 'a briefed task needs the PM\'s instruction' };
125
+ if (findTask(run, s.id)) return { ok: false, error: `task ${s.id} is already in today's run` };
126
+ const next = clone(run);
127
+ next.tasks.push({ id: s.id, item_id: null, title: text(s.title).slice(0, 250) || s.id, kind: 'approve', source: s.source || 'my-work', link: isUrl(s.link) ? s.link : null,
128
+ goal: null, my_work: s.my_work || null, brief_at: s.brief_at || null, instruction: text(s.instruction), context: s.context && typeof s.context === 'object' ? clone(s.context) : null,
129
+ status: 'waiting', session_id: null, started_at: null, attempts: 0, revisions: [], draft: null, decided: null, error: null });
130
+ return { ok: true, run: next, added: s.id };
131
+ }
132
+
114
133
  /**
115
134
  * The tasks to start now, oldest first, so that the sessions running (working, and sending with a session) stay at
116
135
  * most `limit`. An approved task waiting for its send session comes first: the PM is waiting on it.
@@ -260,7 +279,8 @@ function sessionStalled(run, sessionId, why, now) {
260
279
  /** Where the run is: reading (no brief for the day yet), working, review (drafts wait for the PM) or done. */
261
280
  function stage(run, brief) {
262
281
  if (!run) return null;
263
- if (!brief || brief.date !== run.date) return 'reading';
282
+ // a run My work opened (no Prepare my day pressed) has no morning read to wait for
283
+ if ((!brief || brief.date !== run.date) && !(run.opened_by === 'my-work' && !run.brief_session)) return 'reading';
264
284
  if (run.tasks.some((t) => t.status === 'waiting' || t.status === 'working' || t.status === 'sending')) return 'working';
265
285
  if (run.tasks.some((t) => t.status === 'review')) return 'review';
266
286
  return 'done';
@@ -330,5 +350,5 @@ function checkDone(run, { found = 0, resolved = 0 } = {}, now) {
330
350
  }
331
351
 
332
352
  module.exports = { MAX_PARALLEL, MAX_REVISIONS, MAX_CHECK_LOG, MAX_INTERVAL_MIN, STATUSES, KINDS, ACTIONS, TASK_RE,
333
- newRun, readRun, findTask, modeOf, addTasks, toStart, started, deliver, decide, sent, fail, sessionEnded, sessionStalled, stage, intervalMin,
353
+ newRun, readRun, findTask, modeOf, addTasks, addBriefed, toStart, started, deliver, decide, sent, fail, sessionEnded, sessionStalled, stage, intervalMin,
334
354
  checkDue, checkStarted, checkDone };
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+ /* my-work-cli.js — `pmos my-work`: the work assigned to the PM, ranked, from a terminal (initiative my-work, PRD R11).
3
+ *
4
+ * pmos my-work [list] [--top N] [--json] every item, in the order My work shows it, with its tier and state
5
+ * pmos my-work show <ITEM> [--json] one item: the context card, the WSJF parts, the state
6
+ * pmos my-work submit --file <read.json> (the my-work-read skill) submit the read of the PM's assigned Jira issues
7
+ *
8
+ * Every command goes through the running workspace server (GET /api/my-work, POST /api/my-work/read), so a terminal line
9
+ * and the page cannot disagree. Nothing here briefs an agent or takes an item: those are the PM's presses in My work.
10
+ * An ITEM is its id (jira:ATL-491, wbs:<ws>:<item>), its Jira key, or its position in the list.
11
+ *
12
+ * Exit codes: 0 done, 1 refused, 2 usage.
13
+ */
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { parseArgs } = require('./schedule-cli');
17
+
18
+ const COMMANDS = { 'my-work': ['list', 'show', 'submit'] };
19
+ const USAGE = [
20
+ 'usage: pmos my-work [list|show|submit] — the Jira tickets and WBS items assigned to you, ranked',
21
+ ' pmos my-work [list] [--top N] [--json] every item, as My work orders it',
22
+ ' pmos my-work show <ITEM> [--json] one item: what the agent found, the score, the state',
23
+ ' pmos my-work submit --file <read.json> (agent session) submit the read of your assigned Jira issues',
24
+ 'Nothing here briefs an agent or takes an item. Do that in My work.',
25
+ ];
26
+ const BOOL = new Set(['json', 'help']);
27
+ const STATE_WORD = { brief: 'to brief', yours: 'yours', agent: 'agent', ready: 'ready in Agenda', done: 'done' };
28
+
29
+ function parse(argv) {
30
+ const rest = []; const bools = {};
31
+ for (const a of argv) {
32
+ if (a.startsWith('--') && BOOL.has(a.slice(2))) bools[a.slice(2)] = true;
33
+ else if (a === '-h') bools.help = true;
34
+ else rest.push(a);
35
+ }
36
+ const { pos, flags } = parseArgs(rest);
37
+ return { pos, flags: { ...flags, ...bools } };
38
+ }
39
+
40
+ const line = (x, i) => `${i + 1}. ${x.wsjf.tier} ${String(x.wsjf.score).padStart(4)} ${x.key} ${x.title} · ${STATE_WORD[x.state]}${x.sub && x.state === 'agent' ? ' (' + x.sub + ')' : ''}${x.due ? ' · due ' + x.due : ''}${x.from ? ' · from ' + x.from : ''}`;
41
+
42
+ async function run(argv, { say = console.log, call, cwd = process.cwd() } = {}) {
43
+ const { pos, flags } = parse(argv.slice(1));
44
+ const sub = pos[0] || 'list';
45
+ if (flags.help || sub === 'help') { USAGE.forEach((l) => say(l)); return 0; }
46
+ if (!COMMANDS['my-work'].includes(sub)) { USAGE.forEach((l) => say(l)); return 2; }
47
+ const json = !!flags.json;
48
+ const fail = (error, code = 1) => { if (json) say(JSON.stringify({ ok: false, error })); else say(`✗ ${error}`); return code; };
49
+ if (typeof call !== 'function') return fail('the workspace server is not reachable');
50
+
51
+ if (sub === 'submit') {
52
+ if (!flags.file) return fail('usage: pmos my-work submit --file <read.json>', 2);
53
+ let raw;
54
+ try { raw = JSON.parse(fs.readFileSync(path.resolve(cwd, String(flags.file)), 'utf8')); }
55
+ catch (e) { return fail(`could not read ${flags.file} — ${e.message}`); }
56
+ const r = await call('POST', '/api/my-work/read', raw);
57
+ if (!r || r.ok === false) return fail(`refused — ${(r && r.error) || 'the workspace server did not answer'}. Nothing changed.`);
58
+ if (json) say(JSON.stringify(r)); else say(`read saved — ${r.count} item${r.count === 1 ? '' : 's'} at ${r.read_at}. My work shows them to brief.`);
59
+ return 0;
60
+ }
61
+
62
+ const d = await call('GET', '/api/my-work');
63
+ if (!d || d.ok === false) return fail((d && d.error) || 'the workspace server did not answer');
64
+ if (sub === 'list') {
65
+ const top = flags.top === undefined ? null : Number(flags.top);
66
+ if (top !== null && (!Number.isInteger(top) || top < 1)) return fail('--top takes a whole number of 1 or more', 2);
67
+ const open = d.items.filter((x) => x.state !== 'done');
68
+ const items = top ? open.slice(0, top) : open;
69
+ if (json) { say(JSON.stringify({ ok: true, today: d.today, read_at: d.read_at, counts: d.counts, items })); return 0; }
70
+ say(`My work · ${d.counts.all} open · ${d.counts.brief} to brief · ${d.counts.agents} with agents · ${d.counts.ready} ready in Agenda · read ${d.read_at || 'never'}`);
71
+ for (const b of d.banner || []) say(`! ${b.source}: ${b.error}`);
72
+ if (!items.length) say('Nothing assigned to you. Read again in My work, or run the my-work-read skill.');
73
+ items.forEach((x, i) => say(line(x, i)));
74
+ return 0;
75
+ }
76
+ // show
77
+ const ref = String(pos[1] || '');
78
+ if (!ref) return fail('usage: pmos my-work show <ITEM>', 2);
79
+ const open = d.items.filter((x) => x.state !== 'done');
80
+ const x = /^\d+$/.test(ref) ? open[Number(ref) - 1] : d.items.find((y) => y.id === ref || y.key === ref.toUpperCase() || y.key === ref);
81
+ if (!x) return fail(`no item ${ref} — pmos my-work list shows them`);
82
+ if (json) { say(JSON.stringify({ ok: true, item: x })); return 0; }
83
+ say(`${x.key} ${x.title}`);
84
+ say(`${x.source === 'jira' ? 'Jira' : 'WBS'}${x.repo ? ' · ' + x.repo : ''}${x.status ? ' · ' + x.status : ''}${x.url ? ' · ' + x.url : ''}`);
85
+ say(`state: ${STATE_WORD[x.state]}${x.sub ? ' (' + x.sub + ')' : ''}${x.task ? ' · task ' + x.task.id : ''}`);
86
+ const w = x.wsjf;
87
+ say(`${w.tier} · score ${w.score} = (value ${w.value} + urgency ${w.urgency} + unblocks ${w.unblocks}) / size ${w.size}`);
88
+ if (x.from) say(`from: ${x.from}`);
89
+ if (x.ask) say(`ask: ${x.ask}`);
90
+ if (x.thread) say(`thread: ${x.thread}`);
91
+ (x.open || []).forEach((o) => say(`open: ${o}`));
92
+ if (x.kind === 'decide') say('looks like a decision only you can make');
93
+ if (x.instruction) say(`your instruction: ${x.instruction}`);
94
+ else if (x.suggest) say(`suggested instruction: ${x.suggest}`);
95
+ return 0;
96
+ }
97
+
98
+ module.exports = { run, COMMANDS, USAGE };
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+ /* my-work.js — every Jira ticket and WBS item assigned to the PM, ranked, with one state each (initiative my-work, PRD
3
+ * R1 to R5).
4
+ *
5
+ * PURE: no require at all — no file, process, network or clock of its own. The server reads the stored read
6
+ * (<state>/my-work/read.json), the PM's presses (<state>/my-work/actions.jsonl), the Board fold of each registered
7
+ * repository and the day runs, passes them in with `now`, and writes what an edit returns.
8
+ *
9
+ * checkRead(raw, now) → { ok, read } | { ok:false, error } R1: what the my-work-read skill submits
10
+ * wbsItems(boards) → items R2: owner `you` plan items, every repository
11
+ * score(item, today) → { value, urgency, unblocks, size, score, tier } R3
12
+ * parseActions(text) · checkAct(op) → the press log, one press
13
+ * fold({ read, wbs, actions, runs, today }) → { items, counts } R4: ranked, one state each
14
+ * taskSpec(item, instruction, seq) → what lib/day-run.js addBriefed takes R5
15
+ *
16
+ * An item is { id, source: jira|wbs, key, title, url, priority, status, category: todo|doing|done, due, estimate,
17
+ * blocks, from, ask, thread, open, suggest, kind: agent|decide, initiative, repo, ws }. Its id is `jira:<KEY>` or
18
+ * `wbs:<ws>:<item>`, so the same ticket read twice is the same item.
19
+ *
20
+ * States: brief (nobody decided) · yours (the PM takes it) · agent (a day task works on it; `sub` says how) ·
21
+ * ready (its draft waits in Agenda) · done. Derived on every read; no state is stored.
22
+ */
23
+
24
+ const PRIORITY_VALUE = Object.freeze({ Highest: 10, High: 8, Medium: 5, Low: 2 });
25
+ const SIZE = Object.freeze({ XS: 1, S: 2, M: 3, L: 5, XL: 8 });
26
+ const TIERS = Object.freeze([[12, 'P1'], [7, 'P2'], [3, 'P3'], [-Infinity, 'P4']]);
27
+ const STATES = Object.freeze(['brief', 'yours', 'agent', 'ready', 'done']);
28
+ const ACTIONS = Object.freeze(['brief', 'handle', 'reopen']);
29
+ const CATEGORIES = Object.freeze(['todo', 'doing', 'done']);
30
+ const KEY_RE = /^[A-Z][A-Z0-9_]*-\d+$/;
31
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
32
+ const ID_RE = /^(jira:[A-Z][A-Z0-9_]*-\d+|wbs:[\w.-]{1,80}:[\w.-]{1,80})$/;
33
+ const MAX_ITEMS = 200;
34
+ const MAX_INSTRUCTION = 2000;
35
+ const DAY = 86400000;
36
+
37
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
38
+ const oneLine = (s) => String(s == null ? '' : s).replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
39
+ const clip = (s, n) => (s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s);
40
+ const str = (v, n) => clip(oneLine(v), n);
41
+ const pick = (map, v) => Object.keys(map).find((k) => k.toLowerCase() === String(v || '').trim().toLowerCase()) || null;
42
+ const httpsUrl = (v) => { const s = String(v || ''); try { return new URL(s).protocol === 'https:' && s.length <= 500 ? s : null; } catch { return null; } };
43
+ const dateOf = (v) => { const s = String(v || '').slice(0, 10); return DATE_RE.test(s) && !Number.isNaN(Date.parse(s + 'T00:00:00Z')) ? s : null; };
44
+
45
+ /** FNV-1a over the string, as 12 hex digits: a task id that is the same for the same item and brief, with no require. */
46
+ function hex12(s) {
47
+ let h1 = 0x811c9dc5; let h2 = 0x01000193;
48
+ for (let i = 0; i < s.length; i++) {
49
+ const c = s.charCodeAt(i);
50
+ h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
51
+ h2 = Math.imul(h2 ^ c, 0x0100019d) >>> 0;
52
+ }
53
+ return (h1.toString(16).padStart(8, '0') + h2.toString(16).padStart(8, '0')).slice(0, 12);
54
+ }
55
+
56
+ /* ── R1 · the read ────────────────────────────────────────────────────────────────────────────────────────────── */
57
+
58
+ function checkItem(x, i) {
59
+ if (!isObj(x)) return { error: `item ${i + 1} is not an object` };
60
+ const key = oneLine(x.key).toUpperCase();
61
+ if (!KEY_RE.test(key)) return { error: `item ${i + 1}: key must be a Jira key like ATL-491` };
62
+ const title = str(x.title || x.summary, 250);
63
+ if (!title) return { error: `item ${key}: a title is required` };
64
+ if (x.priority != null && x.priority !== '' && !pick(PRIORITY_VALUE, x.priority)) return { error: `item ${key}: priority must be Highest, High, Medium or Low` };
65
+ if (x.estimate != null && x.estimate !== '' && !pick(SIZE, x.estimate)) return { error: `item ${key}: estimate must be XS, S, M, L or XL` };
66
+ if (x.due != null && x.due !== '' && !dateOf(x.due)) return { error: `item ${key}: due must be a date YYYY-MM-DD` };
67
+ const blocks = x.blocks == null ? 0 : Number(x.blocks);
68
+ if (!Number.isInteger(blocks) || blocks < 0 || blocks > 99) return { error: `item ${key}: blocks must be a whole number from 0 to 99` };
69
+ const open = Array.isArray(x.open) ? x.open.map((o) => str(o, 200)).filter(Boolean).slice(0, 5) : [];
70
+ const category = CATEGORIES.includes(x.category) ? x.category : 'todo';
71
+ const initiative = /^[a-z0-9][a-z0-9-]{0,79}$/.test(String(x.initiative || '')) ? x.initiative : null;
72
+ return {
73
+ item: {
74
+ id: 'jira:' + key, source: 'jira', key, title, url: httpsUrl(x.url), priority: pick(PRIORITY_VALUE, x.priority) || null,
75
+ status: str(x.status, 60) || null, category, due: dateOf(x.due), estimate: pick(SIZE, x.estimate) || null, blocks,
76
+ from: str(x.from, 80) || null, ask: str(x.ask, 400) || null, thread: str(x.thread, 600) || null, open,
77
+ suggest: str(x.suggest, MAX_INSTRUCTION) || null, kind: x.kind === 'decide' ? 'decide' : 'agent', initiative, repo: null, ws: null,
78
+ },
79
+ };
80
+ }
81
+
82
+ /** What the my-work-read skill submits: { items: [...] }. Refused whole on any bad item, so a read is never half-kept. */
83
+ function checkRead(raw, now) {
84
+ if (!isObj(raw) || !Array.isArray(raw.items)) return { ok: false, error: 'the read must be an object with an items list' };
85
+ if (raw.items.length > MAX_ITEMS) return { ok: false, error: `at most ${MAX_ITEMS} items in one read` };
86
+ const items = []; const seen = new Set();
87
+ for (let i = 0; i < raw.items.length; i++) {
88
+ const c = checkItem(raw.items[i], i);
89
+ if (c.error) return { ok: false, error: c.error };
90
+ if (seen.has(c.item.id)) return { ok: false, error: `item ${c.item.key} is listed twice` };
91
+ seen.add(c.item.id);
92
+ items.push(c.item);
93
+ }
94
+ return { ok: true, read: { read_at: new Date(now).toISOString(), items } };
95
+ }
96
+
97
+ /* ── R2 · WBS items from the Board fold ───────────────────────────────────────────────────────────────────────── */
98
+
99
+ const WBS_CATEGORY = { todo: 'todo', doing: 'doing', review: 'doing', done: 'done' };
100
+
101
+ /**
102
+ * `boards` = [{ ws, repo, board }] where board is lib/board-fold.js boardFor(…, { filter: 'mine' }). Only items with
103
+ * owner `you` are kept. `blocks` counts the items of the same repository whose `after` names the item.
104
+ */
105
+ function wbsItems(boards) {
106
+ const out = [];
107
+ for (const b of Array.isArray(boards) ? boards : []) {
108
+ if (!b || !isObj(b.board) || !isObj(b.board.items)) continue;
109
+ const all = Object.values(b.board.items);
110
+ const titles = new Map((b.board.initiatives || []).map((i) => [i.id, i.title]));
111
+ for (const it of all) {
112
+ if (!it || it.owner !== 'you' || typeof it.id !== 'string') continue;
113
+ const blocks = all.filter((o) => o && Array.isArray(o.after) && o.after.includes(it.id)).length;
114
+ const id = `wbs:${b.ws}:${it.id}`;
115
+ if (!ID_RE.test(id)) continue;
116
+ out.push({
117
+ id, source: 'wbs', key: it.id, title: str(it.title, 250) || it.id, url: null, priority: null,
118
+ status: str(it.stageLabel || it.stage, 60) || null, category: WBS_CATEGORY[it.state] || 'todo', due: dateOf(it.due), estimate: null,
119
+ blocks, from: titles.get(it.initiative) || it.initiative || null, ask: str(it.note, 400) || null, thread: null, open: [],
120
+ suggest: null, kind: 'agent', initiative: it.initiative || null, repo: b.repo || b.ws, ws: b.ws,
121
+ });
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /* ── R3 · WSJF ─────────────────────────────────────────────────────────────────────────────────────────────────── */
128
+
129
+ function urgencyOf(due, today) {
130
+ if (!due || !DATE_RE.test(String(today || ''))) return 1;
131
+ const days = Math.round((Date.parse(due + 'T00:00:00Z') - Date.parse(today + 'T00:00:00Z')) / DAY);
132
+ if (days < 0) return 10;
133
+ if (days <= 2) return 8;
134
+ if (days <= 7) return 5;
135
+ return 3;
136
+ }
137
+ const tierOf = (s) => TIERS.find(([min]) => s >= min)[1];
138
+
139
+ /** The WSJF parts of one item on the local day `today` (YYYY-MM-DD). */
140
+ function score(item, today) {
141
+ const value = PRIORITY_VALUE[item.priority || 'Medium'] + (item.initiative ? 3 : 1);
142
+ const urgency = urgencyOf(item.due, today);
143
+ const unblocks = Math.min(9, 3 * (Number(item.blocks) || 0));
144
+ const size = SIZE[item.estimate || 'M'];
145
+ const s = Math.round(((value + urgency + unblocks) / size) * 10) / 10;
146
+ return { value, urgency, unblocks, size, score: s, tier: tierOf(s) };
147
+ }
148
+
149
+ /* ── the press log ─────────────────────────────────────────────────────────────────────────────────────────────── */
150
+
151
+ /** One press from the page: { action: brief|handle|reopen, item, instruction? }. Returns the record to append. */
152
+ function checkAct(op, now) {
153
+ const o = isObj(op) ? op : {};
154
+ if (!ACTIONS.includes(o.action)) return { ok: false, error: `action must be one of ${ACTIONS.join(', ')}` };
155
+ const item = String(o.item || '');
156
+ if (!ID_RE.test(item)) return { ok: false, error: 'item must be an item id like jira:ATL-491' };
157
+ const rec = { action: o.action, item, at: new Date(now).toISOString() };
158
+ if (o.action === 'brief') {
159
+ const ins = typeof o.instruction === 'string' ? o.instruction.trim() : '';
160
+ if (!ins) return { ok: false, error: 'say what the agent should do: a brief needs an instruction' };
161
+ if (ins.length > MAX_INSTRUCTION) return { ok: false, error: `the instruction is over ${MAX_INSTRUCTION} characters` };
162
+ rec.instruction = ins;
163
+ }
164
+ return { ok: true, record: rec };
165
+ }
166
+
167
+ /** The append-only log as text → the presses, oldest first. A torn or unknown line is skipped. */
168
+ function parseActions(text) {
169
+ const out = [];
170
+ for (const line of String(text || '').split('\n')) {
171
+ if (!line.trim()) continue;
172
+ let r; try { r = JSON.parse(line); } catch { continue; }
173
+ if (isObj(r) && ACTIONS.includes(r.action) && ID_RE.test(String(r.item || '')) && typeof r.at === 'string') out.push(r);
174
+ }
175
+ return out;
176
+ }
177
+
178
+ /* ── R4 · the fold ─────────────────────────────────────────────────────────────────────────────────────────────── */
179
+
180
+ const AGENT_SUBS = ['waiting', 'working', 'sending', 'failed'];
181
+
182
+ /** The day task that works on `item` for its last brief, from the run of that brief's day. */
183
+ function taskOf(runs, brief) {
184
+ const day = String(brief.at).slice(0, 10);
185
+ const pool = [];
186
+ for (const [d, run] of Object.entries(isObj(runs) ? runs : {})) if (run && Array.isArray(run.tasks) && (d >= day)) pool.push(...run.tasks);
187
+ return pool.filter((t) => t && t.my_work === brief.item && t.brief_at === brief.at).pop() || null;
188
+ }
189
+
190
+ /** The state of one item: { state, sub, task, instruction, decided_at }. */
191
+ function stateOf(item, presses, runs) {
192
+ if (item.category === 'done') return { state: 'done', sub: 'source', task: null, instruction: null };
193
+ const last = presses[presses.length - 1] || null;
194
+ if (!last || last.action === 'reopen') return { state: 'brief', sub: null, task: null, instruction: null };
195
+ if (last.action === 'handle') return { state: 'yours', sub: null, task: null, instruction: null, decided_at: last.at };
196
+ const t = taskOf(runs, last);
197
+ const task = t ? { id: t.id, status: t.status, error: t.error || null, draft: t.draft ? t.draft.summary : null } : null;
198
+ const base = { task, instruction: last.instruction, decided_at: last.at };
199
+ if (!t) return { state: 'agent', sub: 'waiting', ...base };
200
+ if (t.status === 'review') return { state: 'ready', sub: 'review', ...base };
201
+ if (t.status === 'sent' || t.status === 'done') return { state: 'done', sub: t.status, ...base };
202
+ return { state: 'agent', sub: AGENT_SUBS.includes(t.status) ? t.status : 'waiting', ...base };
203
+ }
204
+
205
+ /** May the PM press `action` on an item in this state? Null when yes, else why not. */
206
+ function refusal(st, action) {
207
+ if (action === 'brief' || action === 'handle') return st.state === 'brief' ? null : `the item is ${st.state}, not waiting for a brief — press Back to brief first`;
208
+ if (st.state === 'brief') return 'the item is already waiting for a brief';
209
+ if (st.state === 'done' && st.sub === 'source') return 'the item is done in its source';
210
+ if (st.state === 'agent' && (st.sub === 'working' || st.sub === 'sending')) return 'an agent is working on it — wait for its draft in Agenda';
211
+ return null;
212
+ }
213
+
214
+ /**
215
+ * Every item, ranked, with its state. `read` is the stored read or null; `wbs` is wbsItems(…); `actions` is
216
+ * parseActions(…); `runs` maps a date to that day's run. Counts: brief, agents, ready, yours, all (not done).
217
+ */
218
+ function fold({ read, wbs, actions, runs, today } = {}) {
219
+ const by = new Map();
220
+ for (const it of (read && Array.isArray(read.items) ? read.items : [])) by.set(it.id, it);
221
+ for (const it of Array.isArray(wbs) ? wbs : []) if (!by.has(it.id)) by.set(it.id, it);
222
+ const presses = new Map();
223
+ for (const a of Array.isArray(actions) ? actions : []) { if (!presses.has(a.item)) presses.set(a.item, []); presses.get(a.item).push(a); }
224
+ const items = [...by.values()].map((it) => ({ ...it, wsjf: score(it, today), ...stateOf(it, presses.get(it.id) || [], runs) }));
225
+ items.sort((a, b) => (b.wsjf.score - a.wsjf.score) || String(a.due || '9999').localeCompare(String(b.due || '9999')) || a.id.localeCompare(b.id));
226
+ const n = (s) => items.filter((x) => x.state === s).length;
227
+ const counts = { brief: n('brief'), agents: n('agent'), ready: n('ready'), yours: n('yours'), done: n('done'), all: items.filter((x) => x.state !== 'done').length };
228
+ return { items, counts };
229
+ }
230
+
231
+ /* ── R5 · the day task a brief adds ───────────────────────────────────────────────────────────────────────────── */
232
+
233
+ /** What lib/day-run.js addBriefed takes for a brief recorded as `rec` on `item`. */
234
+ function taskSpec(item, rec) {
235
+ return {
236
+ id: 't-' + hex12(item.id + '|' + rec.at), my_work: item.id, brief_at: rec.at, title: item.key + ' · ' + item.title,
237
+ source: item.source, link: item.url, instruction: rec.instruction,
238
+ context: { key: item.key, from: item.from, ask: item.ask, thread: item.thread, open: item.open, repo: item.repo, ws: item.ws || null, initiative: item.initiative, due: item.due },
239
+ };
240
+ }
241
+
242
+ module.exports = { PRIORITY_VALUE, SIZE, STATES, ACTIONS, MAX_INSTRUCTION, ID_RE, checkRead, wbsItems, score, urgencyOf, checkAct, parseActions, stateOf, refusal, fold, taskSpec, hex12 };
@@ -44,6 +44,9 @@ const VERBS = Object.freeze({
44
44
  // Not a pm-os-absorb verb (run: null): people-directory added it through the same table.
45
45
  people: Object.freeze({ module: 'people-cli.js', run: null, guide: 'people.md',
46
46
  help: 'pmos people list|show|record|settings … — the people you work with in this repository (pmos people help)' }),
47
+ // Not a pm-os-absorb verb (run: null): initiative my-work added it through the same table.
48
+ 'my-work': Object.freeze({ module: 'my-work-cli.js', run: null, guide: 'my-work.md',
49
+ help: 'pmos my-work list|show|submit … — the Jira tickets and WBS items assigned to you, ranked (pmos my-work help)' }),
47
50
  launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
48
51
  help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
49
52
  });
@@ -275,6 +275,16 @@ async function run(argv, { call, say, cwd = process.cwd() }) {
275
275
  for (const k of ['where', 'who', 'about', 'ask', 'how']) if (it[k]) say(`${k}: ${it[k]}`);
276
276
  if (it.reply_needed !== undefined) say(`reply needed: ${it.reply_needed ? 'yes' : 'no'}`);
277
277
  if (it.initiative_id) say(`initiative: ${it.initiative_id}`);
278
+ } else if (t.my_work) {
279
+ // a job the PM briefed in My work (initiative my-work, R6): the PM's instruction is the ask
280
+ const c = t.context || {};
281
+ say(`from My work: ${t.my_work}${c.repo ? ' · repository ' + c.repo : ''}${c.due ? ' · due ' + c.due : ''}`);
282
+ if (c.from) say(`who: ${c.from}`);
283
+ if (c.ask) say(`about: ${c.ask}`);
284
+ if (c.thread) say(`thread: ${c.thread}`);
285
+ for (const o of Array.isArray(c.open) ? c.open : []) say(`open question: ${o}`);
286
+ if (c.initiative) say(`initiative: ${c.initiative}`);
287
+ say(`instruction from the PM (this is the ask): ${t.instruction}`);
278
288
  } else say('(the item is no longer in today\'s brief)');
279
289
  if (t.kind === 'approve') say('kind approve: deliver ONE draft; nothing goes out until the PM approves it.');
280
290
  else say('kind auto: deliver ONE draft; nothing leaves this machine.');
@@ -37,6 +37,7 @@ const MODULES = Object.freeze([
37
37
  'routes-board', // Run 4 (4D): the cross-initiative Board, Release
38
38
  'routes-reports', // Run 5 (5C): drafts, Issue, notes
39
39
  'routes-notes', // meeting-notes Run 1: the Notes view (typed notes, dictation, transcripts, summaries)
40
+ 'routes-my-work', // initiative my-work: the ranked list of work assigned to the PM, the brief, Read again
40
41
  ]);
41
42
  const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
42
43
  const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;