create-pmos 0.71.1 → 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.
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+ /* routes-my-work.js — the My work destination's routes (initiative my-work, PRD R1 to R5, R11).
3
+ *
4
+ * GET /api/my-work → { ok, today, read_at, items, counts, banner } every item assigned to the PM,
5
+ * ranked (lib/my-work.js fold), across every registered repository
6
+ * POST /api/my-work/read { items } → { ok, read_at, count } the my-work-read skill's submission (`pmos my-work submit`)
7
+ * POST /api/my-work/act { action: brief|handle|reopen, item, instruction?, ws }
8
+ * → { ok, item, task? } the PM's press; a brief adds one day task (R5)
9
+ * POST /api/my-work/refresh { ws } → { ok, session } Read again: starts the my-work-read skill
10
+ *
11
+ * STATE. The latest read is <runtime>/my-work/read.json and the presses are <runtime>/my-work/actions.jsonl, append-only.
12
+ * Both stay in the per-user runtime state and never land in a repository: they hold paraphrases of Jira comments. The
13
+ * WBS items are folded from each repository's Board (lib/board-fold.js, filter mine) on every GET, and nothing is
14
+ * written to a plan ledger (D88).
15
+ *
16
+ * THE SERVER NEVER READS JIRA (D53, D71). An agent session reads it with the PM's own connection and posts the read.
17
+ * The two presses that start a session (act brief, refresh) pass pm-guard requirePagePress first: an agent session
18
+ * cannot brief itself or another agent.
19
+ */
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const guard = require('./pm-guard.js');
23
+ const myWork = require('./my-work.js');
24
+ const boardFold = require('./board-fold.js');
25
+ const { localDate } = require('./brief.js');
26
+
27
+ const dirOf = (ctx) => path.join(ctx.runtimeDir, 'my-work');
28
+ const readFile = (ctx) => path.join(dirOf(ctx), 'read.json');
29
+ const actionsFile = (ctx) => path.join(dirOf(ctx), 'actions.jsonl');
30
+
31
+ function loadRead(ctx) {
32
+ try { const r = JSON.parse(fs.readFileSync(readFile(ctx), 'utf8')); return r && Array.isArray(r.items) ? r : null; } catch { return null; }
33
+ }
34
+ function loadActions(ctx) {
35
+ try { return myWork.parseActions(fs.readFileSync(actionsFile(ctx), 'utf8')); } catch { return []; }
36
+ }
37
+
38
+ /** The whole list, as GET answers it. */
39
+ function listFor(ctx, now) {
40
+ const today = localDate(now);
41
+ const banner = [];
42
+ const boards = [];
43
+ const all = typeof ctx.workspaceList === 'function' ? ctx.workspaceList() : [];
44
+ for (const w of all) {
45
+ try {
46
+ const b = boardFold.boardFor({ id: w.id, root: w.root }, { filter: 'mine', now });
47
+ if (b.ok) boards.push({ ws: w.id, repo: w.name || path.basename(w.root), board: b });
48
+ else banner.push({ source: 'wbs:' + w.id, error: b.error });
49
+ } catch (e) { banner.push({ source: 'wbs:' + w.id, error: String((e && e.message) || e).slice(0, 160) }); }
50
+ }
51
+ const read = loadRead(ctx);
52
+ const actions = loadActions(ctx);
53
+ const runs = {};
54
+ for (const d of new Set(actions.filter((a) => a.action === 'brief').map((a) => String(a.at).slice(0, 10)).concat([today]))) {
55
+ try { const r = ctx.readDayRun ? ctx.readDayRun(d) : null; if (r) runs[d] = r; } catch { /* a run that cannot be read is no run */ }
56
+ }
57
+ // a run of a later day can hold a task too: today's covers every brief made today
58
+ const out = myWork.fold({ read, wbs: myWork.wbsItems(boards), actions, runs, today });
59
+ return { ok: true, today, read_at: read ? read.read_at || null : null, items: out.items, counts: out.counts, banner };
60
+ }
61
+
62
+ function writeAtomic(file, text) {
63
+ fs.mkdirSync(path.dirname(file), { recursive: true });
64
+ const tmp = `${file}.${process.pid}.tmp`;
65
+ fs.writeFileSync(tmp, text);
66
+ fs.renameSync(tmp, file);
67
+ }
68
+
69
+ async function handle(req, res, url, ctx) {
70
+ const p = url.pathname;
71
+ const now = Date.now();
72
+ if (p === '/api/my-work') {
73
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
74
+ ctx.json(res, listFor(ctx, now));
75
+ return true;
76
+ }
77
+ if (!['/api/my-work/read', '/api/my-work/act', '/api/my-work/refresh'].includes(p)) return false;
78
+ if (req.method !== 'POST') { ctx.json(res, { ok: false, error: 'POST only' }, 405); return true; }
79
+ const got = await ctx.readJson(req);
80
+ if (!got.ok) { ctx.json(res, { ok: false, error: 'invalid JSON body' }, 400); return true; }
81
+ const body = got.body;
82
+
83
+ if (p === '/api/my-work/read') {
84
+ const c = myWork.checkRead(body, now);
85
+ if (!c.ok) { ctx.json(res, { ok: false, error: c.error }, 400); return true; }
86
+ try { writeAtomic(readFile(ctx), JSON.stringify(c.read) + '\n'); }
87
+ catch { ctx.json(res, { ok: false, error: 'could not save the read — nothing changed' }, 500); return true; }
88
+ ctx.json(res, { ok: true, read_at: c.read.read_at, count: c.read.items.length });
89
+ return true;
90
+ }
91
+
92
+ const g = guard.requirePagePress(req, body);
93
+ if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
94
+
95
+ if (p === '/api/my-work/refresh') {
96
+ const r = ctx.myWorkRead(String(body.ws || ''));
97
+ ctx.json(res, r.ok ? { ok: true, session: r.session } : { ok: false, error: r.error }, r.ok ? 200 : r.status || 500);
98
+ return true;
99
+ }
100
+
101
+ // act
102
+ const c = myWork.checkAct(body, now);
103
+ if (!c.ok) { ctx.json(res, { ok: false, error: c.error }, 400); return true; }
104
+ const list = listFor(ctx, now);
105
+ const item = list.items.find((x) => x.id === c.record.item);
106
+ if (!item) { ctx.json(res, { ok: false, error: `no item ${c.record.item} is assigned to you` }, 404); return true; }
107
+ const why = myWork.refusal(item, c.record.action);
108
+ if (why) { ctx.json(res, { ok: false, error: why }, 409); return true; }
109
+ let task = null;
110
+ if (c.record.action === 'brief') {
111
+ const b = ctx.myWorkBrief(myWork.taskSpec(item, c.record), String(body.ws || ''));
112
+ if (!b.ok) { ctx.json(res, { ok: false, error: b.error }, b.status || 500); return true; }
113
+ task = b.task;
114
+ }
115
+ try { fs.mkdirSync(dirOf(ctx), { recursive: true }); fs.appendFileSync(actionsFile(ctx), JSON.stringify(c.record) + '\n'); }
116
+ catch { ctx.json(res, { ok: false, error: 'could not save your press' }, 500); return true; }
117
+ const after = listFor(ctx, Date.now()).items.find((x) => x.id === item.id) || null;
118
+ ctx.json(res, { ok: true, item: after, task });
119
+ return true;
120
+ }
121
+
122
+ module.exports = { prefixes: ['/api/my-work'], handle, listFor };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.71.1",
3
+ "version": "0.72.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.71.1",
3
+ "version": "0.72.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -406,7 +406,7 @@ function render() {
406
406
 
407
407
  const FRAME = !FIXTURE && !LEGACY_LAYOUT_FLAG;
408
408
  const FRAME_KEY = 'pmos.workspace.pm.frame';
409
- const FRAME_DESTS = Object.freeze(['today', 'initiatives', 'board', 'reports', 'docs', 'people', 'settings']);
409
+ const FRAME_DESTS = Object.freeze(['today', 'mywork', 'initiatives', 'board', 'reports', 'docs', 'people', 'settings']);
410
410
 
411
411
  function readFrame() {
412
412
  if (!FRAME) return {};
@@ -654,7 +654,7 @@ function pressIn(r, key) {
654
654
  if (b && !b.disabled) { b.click(); return true; }
655
655
  return false;
656
656
  }
657
- const G_KEYS = { t: 'today', i: 'initiatives', b: 'board', r: 'reports', d: 'docs', p: 'people' };
657
+ const G_KEYS = { t: 'today', i: 'initiatives', b: 'board', r: 'reports', d: 'docs', p: 'people', w: 'mywork' };
658
658
 
659
659
  function frameKey(e) {
660
660
  const mod = e.metaKey || e.ctrlKey;
@@ -5653,6 +5653,123 @@
5653
5653
  }
5654
5654
  }
5655
5655
 
5656
+ /* My work (my-work): the work assigned to the PM, ranked, beside the chosen item's context card and brief. */
5657
+ .rp-mywork {
5658
+ display: flex;
5659
+ flex-direction: column;
5660
+ gap: 16px;
5661
+ }
5662
+ .rp-mywork-top {
5663
+ display: flex;
5664
+ flex-wrap: wrap;
5665
+ align-items: center;
5666
+ gap: 12px;
5667
+ }
5668
+ .rp-mywork-top > .rp-head__title {
5669
+ margin-right: auto;
5670
+ }
5671
+ .rp-mywork-body {
5672
+ display: flex;
5673
+ flex-direction: column;
5674
+ gap: 16px;
5675
+ }
5676
+ .rp-mywork-grid {
5677
+ display: grid;
5678
+ grid-template-columns: minmax(280px, 3fr) 2fr;
5679
+ gap: 24px;
5680
+ align-items: start;
5681
+ }
5682
+ .rp-mywork-list {
5683
+ display: flex;
5684
+ flex-direction: column;
5685
+ gap: 8px;
5686
+ min-width: 0;
5687
+ }
5688
+ .rp-mywork-views {
5689
+ border-bottom: 1px solid var(--border);
5690
+ }
5691
+ .rp-mywork-row {
5692
+ border-top: 1px solid var(--border);
5693
+ }
5694
+ .rp-mywork-row:last-child {
5695
+ border-bottom: 1px solid var(--border);
5696
+ }
5697
+ .rp-mywork-row--open {
5698
+ box-shadow: inset 2px 0 0 var(--foreground);
5699
+ }
5700
+ .rp-mywork-state {
5701
+ display: inline-flex;
5702
+ align-items: center;
5703
+ color: var(--muted-foreground);
5704
+ }
5705
+ .rp-mywork-page {
5706
+ display: flex;
5707
+ flex-direction: column;
5708
+ gap: 16px;
5709
+ min-width: 0;
5710
+ }
5711
+ .rp-mywork-who {
5712
+ display: flex;
5713
+ flex-direction: column;
5714
+ gap: 4px;
5715
+ }
5716
+ .rp-mywork-who__title {
5717
+ margin: 0;
5718
+ font-size: 16px;
5719
+ font-weight: 500;
5720
+ }
5721
+ .rp-mywork-link {
5722
+ display: inline-flex;
5723
+ align-items: center;
5724
+ gap: 4px;
5725
+ font-size: 12px;
5726
+ color: var(--foreground);
5727
+ }
5728
+ .rp-mywork-sec,
5729
+ .rp-mywork-brief,
5730
+ .rp-mywork-status {
5731
+ display: flex;
5732
+ flex-direction: column;
5733
+ gap: 8px;
5734
+ }
5735
+ .rp-mywork-sec__title,
5736
+ .rp-mywork-sub {
5737
+ margin: 0;
5738
+ font-size: 12px;
5739
+ font-weight: 500;
5740
+ color: var(--muted-foreground);
5741
+ }
5742
+ .rp-mywork-hint {
5743
+ margin: 0;
5744
+ font-size: 12px;
5745
+ color: var(--muted-foreground);
5746
+ }
5747
+ .rp-mywork-text {
5748
+ margin: 0;
5749
+ font-size: 14px;
5750
+ overflow-wrap: anywhere;
5751
+ }
5752
+ .rp-mywork-quote {
5753
+ padding-left: 8px;
5754
+ border-left: 2px solid var(--border);
5755
+ white-space: pre-wrap;
5756
+ }
5757
+ .rp-mywork-open {
5758
+ margin: 0;
5759
+ padding-left: 16px;
5760
+ font-size: 14px;
5761
+ }
5762
+ .rp-mywork-acts {
5763
+ display: flex;
5764
+ flex-wrap: wrap;
5765
+ gap: 8px;
5766
+ }
5767
+ @media (max-width: 760px) {
5768
+ .rp-mywork-grid {
5769
+ grid-template-columns: 1fr;
5770
+ }
5771
+ }
5772
+
5656
5773
  /* -- layouts · .nt- Notes: one meeting on one screen — notes and what was caught beside a quiet transcript (meeting-notes Runs 1 and 2) -- */
5657
5774
  .nt-wrap {
5658
5775
  display: flex;
@@ -0,0 +1,242 @@
1
+ /* pm/dest-mywork.js — the My work destination: every Jira ticket and WBS item assigned to the PM, ranked, briefed to an
2
+ * agent, approved in Agenda (initiative my-work, PRD R3 to R5, R7, R10).
3
+ *
4
+ * A DESTINATION MODULE (registry.js): mountDestination(host, ctx) on every render with the same host, disposeDestination(host).
5
+ * It keeps ONE persistent root per host and rebuilds the body only when its own data or a choice changed.
6
+ *
7
+ * What it shows:
8
+ * - the tabs To brief (the default) · Agents · Ready · All, each with its count, and Read again;
9
+ * - the list, in WSJF order: the tier, the title, a meta line (Jira or WBS, the key, who asked, the due date) and a
10
+ * state icon;
11
+ * - the chosen item: What the agent found (the ask, the thread, the open questions), the instruction box prefilled
12
+ * with the agent's suggestion, Run agent and I'll handle it; Back to brief when the item is the PM's or an agent's;
13
+ * Open Agenda when its draft waits there; Details with each part of the score.
14
+ * Every press is a page press to POST /api/my-work/act or /api/my-work/refresh. Approve, revise and retry are Agenda's
15
+ * (R7): there is no approve button here. In fixture mode it reads nothing.
16
+ */
17
+
18
+ import { el } from './dom.js';
19
+ import { button, row, field, toast, empty, tabs, badge, ensureComponentsCss } from './primitives.js';
20
+ import { icon, ensureSprite } from './icons.js';
21
+ import { pmPost } from './http.js';
22
+ import { wsOf } from './data.js';
23
+ import { registerCommand, openDestination } from './registry.js';
24
+ import { myWork, loadMyWork, onMyWork } from './my-work-data.js';
25
+
26
+ export const COPY = Object.freeze({
27
+ title: 'My work',
28
+ fixture: 'My work shows the work assigned to you in a real session.',
29
+ loading: 'Reading…',
30
+ readError: 'My work could not be read.',
31
+ readAgain: 'Read again',
32
+ readStarted: 'The read started in a session. My work updates when it submits.',
33
+ readAt: (t) => (t ? 'Jira read ' + t : 'Jira not read yet. Press Read again.'),
34
+ tabs: { brief: 'To brief', agents: 'Agents', ready: 'Ready', all: 'All' },
35
+ none: { brief: 'Nothing waits for a brief.', agents: 'No agent is working on your items.', ready: 'No draft waits for you.', all: 'Nothing is assigned to you.' },
36
+ pick: 'Choose an item to see what the agent found.',
37
+ found: 'What the agent found',
38
+ ask: 'Ask', thread: 'Thread', open: 'Still open',
39
+ noCard: 'No context card yet. Read again to have an agent read this ticket.',
40
+ decide: 'This looks like a decision only you can make. An agent can still prepare it.',
41
+ instruction: 'Instruction for the agent',
42
+ placeholder: 'Tell the agent what to do. It drafts, and sends nothing until you approve it in Agenda.',
43
+ run: 'Run agent',
44
+ handle: "I'll handle it",
45
+ back: 'Back to brief',
46
+ agenda: 'Open Agenda',
47
+ source: 'Open the source',
48
+ details: 'Details', hide: 'Hide details',
49
+ score: (w) => `${w.tier} · score ${w.score} = (value ${w.value} + urgency ${w.urgency} + unblocks ${w.unblocks}) / size ${w.size}`,
50
+ yourInstruction: 'Your instruction',
51
+ started: 'The agent is on it. Its draft will wait for you in Agenda.',
52
+ taken: 'It is yours.',
53
+ reopened: 'Back to brief.',
54
+ state: {
55
+ brief: 'Needs your brief', yours: 'You handle it', ready: 'Draft ready in Agenda', done: 'Done',
56
+ agent: { waiting: 'Agent queued', working: 'Agent working', sending: 'Agent sending', failed: 'Agent stopped' },
57
+ },
58
+ due: (d) => 'due ' + d,
59
+ from: (f) => 'from ' + f,
60
+ });
61
+
62
+ registerCommand({ id: 'my-work-open', label: 'My work', hint: 'Every Jira ticket and WBS item assigned to you, ranked', cli: 'pmos my-work list', run: () => openDestination('mywork') });
63
+
64
+ const TAB_STATES = { brief: ['brief'], agents: ['agent'], ready: ['ready'], all: ['brief', 'yours', 'agent', 'ready'] };
65
+ const STATE_ICON = { brief: 'circle-alert', yours: 'flag', ready: 'check', done: 'square-check-big' };
66
+ const AGENT_ICON = { waiting: 'clock', working: 'bot', sending: 'bot', failed: 'circle-alert' };
67
+
68
+ const stateWord = (x) => (x.state === 'agent' ? COPY.state.agent[x.sub] || COPY.state.agent.waiting : COPY.state[x.state]);
69
+ const stateIcon = (x) => {
70
+ const node = icon(x.state === 'agent' ? AGENT_ICON[x.sub] || 'clock' : STATE_ICON[x.state] || 'circle-alert');
71
+ return el('span', { class: 'rp-mywork-state', role: 'img', 'aria-label': stateWord(x), title: stateWord(x), 'data-my-work-state': x.state }, node);
72
+ };
73
+
74
+ /* ── mount ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
75
+
76
+ const insts = new WeakMap();
77
+
78
+ export function mountDestination(host, ctx) {
79
+ let inst = insts.get(host);
80
+ if (!inst) {
81
+ inst = { host, root: el('div', { class: 'rp-page rp-mywork', 'data-my-work': '' }), body: el('div', { class: 'rp-mywork-body' }),
82
+ head: null, tab: 'brief', open: null, drafts: new Map(), details: false, busy: false, last: '', ws: null, off: null };
83
+ insts.set(host, inst);
84
+ const held = inst;
85
+ inst.off = onMyWork(() => draw(held));
86
+ held.body.addEventListener('focusout', () => setTimeout(() => { if (!held.last) draw(held); }, 0));
87
+ }
88
+ ensureComponentsCss();
89
+ if (!inst.root.isConnected || inst.root.parentNode !== host) host.replaceChildren(inst.root);
90
+ if (ctx && ctx.FIXTURE) { inst.root.replaceChildren(empty({ text: COPY.fixture })); return; }
91
+ const repo = (ctx && ctx.tab && ctx.tab.repo) || (ctx && ctx.state && ctx.state.product) || null;
92
+ inst.ws = repo ? wsOf(repo) : null;
93
+ ensureSprite();
94
+ loadMyWork();
95
+ draw(inst);
96
+ }
97
+
98
+ export function disposeDestination(host) {
99
+ const inst = insts.get(host);
100
+ if (!inst) return;
101
+ if (inst.off) inst.off();
102
+ inst.root.remove();
103
+ insts.delete(host);
104
+ }
105
+
106
+ /* ── presses ─────────────────────────────────────────────────────────────────────────────────────────────────────── */
107
+
108
+ async function act(inst, body, done) {
109
+ if (inst.busy) return null;
110
+ inst.busy = true; inst.last = ''; draw(inst);
111
+ const r = await pmPost('/api/my-work/act', { ...body, ws: inst.ws });
112
+ inst.busy = false;
113
+ if (!r || !r.ok) toast({ text: (r && r.error) || 'Nothing was saved.', tone: 'error' });
114
+ else if (done) toast({ text: done, timeout: 4000 });
115
+ await loadMyWork(true);
116
+ return r && r.ok ? r : null;
117
+ }
118
+
119
+ async function readAgain(inst) {
120
+ const r = await pmPost('/api/my-work/refresh', { ws: inst.ws });
121
+ toast(r && r.ok ? { text: COPY.readStarted, timeout: 5000 } : { text: (r && r.error) || 'The read did not start.', tone: 'error' });
122
+ }
123
+
124
+ /* ── the list ────────────────────────────────────────────────────────────────────────────────────────────────────── */
125
+
126
+ function itemRow(inst, x) {
127
+ const open = inst.open === x.id;
128
+ const meta = [x.source === 'jira' ? 'Jira ' + x.key : 'WBS ' + (x.repo ? x.repo + ' · ' : '') + x.key];
129
+ if (x.from) meta.push(COPY.from(x.from));
130
+ if (x.due) meta.push(COPY.due(x.due));
131
+ return el('div', { class: 'rp-mywork-row' + (open ? ' rp-mywork-row--open' : ''), role: 'listitem', 'data-my-work-item': x.id },
132
+ row({
133
+ title: x.title,
134
+ meta,
135
+ lead: [badge({ text: x.wsjf.tier, tone: x.wsjf.tier === 'P1' ? 'warning' : 'outline', attrs: { 'data-my-work-tier': x.wsjf.tier, title: COPY.score(x.wsjf) } })],
136
+ trail: [x.kind === 'decide' && x.state === 'brief' ? el('span', { class: 'rp-mywork-state', role: 'img', 'aria-label': COPY.decide, title: COPY.decide }, icon('diamond')) : null, stateIcon(x)].filter(Boolean),
137
+ onOpen: () => { inst.open = x.id; inst.details = false; inst.last = ''; draw(inst); },
138
+ openLabel: `${x.title}, ${x.wsjf.tier}, ${stateWord(x)}`,
139
+ openAttrs: { 'aria-current': open ? 'true' : null, 'data-my-work-open': x.id },
140
+ }));
141
+ }
142
+
143
+ function listPane(inst, d) {
144
+ const shown = d.items.filter((x) => TAB_STATES[inst.tab].includes(x.state));
145
+ const counts = { brief: d.counts.brief, agents: d.counts.agents, ready: d.counts.ready, all: d.counts.all };
146
+ const controls = el('div', { class: 'rp-mywork-views' },
147
+ tabs({ variant: 'line', value: inst.tab, ariaLabel: 'Show', attrs: { 'data-my-work-tabs': '' },
148
+ items: Object.keys(COPY.tabs).map((id) => ({ id, label: COPY.tabs[id], count: counts[id] })),
149
+ onChange: (v) => { inst.tab = v; inst.open = null; inst.last = ''; draw(inst); } }));
150
+ const rows = shown.length
151
+ ? el('div', { role: 'list', 'data-my-work-list': inst.tab }, shown.map((x) => itemRow(inst, x)))
152
+ : el('p', { class: 'rp-section__empty' }, COPY.none[inst.tab]);
153
+ return el('div', { class: 'rp-mywork-list' }, controls, rows);
154
+ }
155
+
156
+ /* ── the item ────────────────────────────────────────────────────────────────────────────────────────────────────── */
157
+
158
+ function section(title, kids, attrs) {
159
+ return el('section', { class: 'rp-mywork-sec', ...(attrs || {}) }, el('h4', { class: 'rp-mywork-sec__title' }, title), ...kids);
160
+ }
161
+
162
+ function foundSec(x) {
163
+ const kids = [];
164
+ if (!x.ask && !x.thread && !(x.open || []).length) kids.push(el('p', { class: 'rp-mywork-hint' }, x.source === 'jira' ? COPY.noCard : x.status || ''));
165
+ if (x.ask) kids.push(el('p', { class: 'rp-mywork-text', 'data-my-work-ask': '' }, el('strong', null, COPY.ask + ': '), x.ask));
166
+ if (x.thread) kids.push(el('p', { class: 'rp-mywork-text' }, el('strong', null, COPY.thread + ': '), x.thread));
167
+ if ((x.open || []).length) kids.push(el('p', { class: 'rp-mywork-sub' }, COPY.open), el('ul', { class: 'rp-mywork-open' }, x.open.map((o) => el('li', null, o))));
168
+ if (x.kind === 'decide') kids.push(el('p', { class: 'rp-mywork-hint', 'data-my-work-decide': '' }, COPY.decide));
169
+ return section(COPY.found, kids, { 'data-my-work-sec': 'found' });
170
+ }
171
+
172
+ function briefSec(inst, x) {
173
+ const f = field({ label: COPY.instruction, kind: 'textarea', hideLabel: true, placeholder: COPY.placeholder, value: inst.drafts.has(x.id) ? inst.drafts.get(x.id) : (x.suggest || ''), attrs: { 'data-my-work-field': 'instruction' } });
174
+ f.control.addEventListener('input', () => inst.drafts.set(x.id, f.control.value));
175
+ const run = button({ label: COPY.run, variant: 'primary', size: 'sm', icon: 'bot', disabled: inst.busy, attrs: { 'data-my-work-act': 'brief' },
176
+ onClick: async () => {
177
+ const instruction = f.control.value.trim();
178
+ const r = await act(inst, { action: 'brief', item: x.id, instruction }, COPY.started);
179
+ if (r) inst.drafts.delete(x.id);
180
+ } });
181
+ const mine = button({ label: COPY.handle, variant: 'outline', size: 'sm', disabled: inst.busy, attrs: { 'data-my-work-act': 'handle' },
182
+ onClick: () => act(inst, { action: 'handle', item: x.id }, COPY.taken) });
183
+ return el('div', { class: 'rp-mywork-brief' }, f, el('div', { class: 'rp-mywork-acts' }, run, mine));
184
+ }
185
+
186
+ function statusSec(inst, x) {
187
+ const kids = [el('p', { class: 'rp-mywork-text', 'data-my-work-status': x.state + (x.sub ? ':' + x.sub : '') }, stateIcon(x), ' ', stateWord(x))];
188
+ if (x.task && x.task.error) kids.push(el('p', { class: 'rp-mywork-hint' }, x.task.error));
189
+ if (x.task && x.task.draft) kids.push(el('p', { class: 'rp-mywork-hint' }, x.task.draft));
190
+ if (x.instruction) kids.push(el('p', { class: 'rp-mywork-sub' }, COPY.yourInstruction), el('p', { class: 'rp-mywork-text rp-mywork-quote' }, x.instruction));
191
+ const acts = [];
192
+ if (x.state === 'ready' || (x.state === 'agent' && x.sub === 'failed')) acts.push(button({ label: COPY.agenda, variant: 'primary', size: 'sm', attrs: { 'data-my-work-act': 'agenda' }, onClick: () => openDestination('today') }));
193
+ const busyAgent = x.state === 'agent' && (x.sub === 'working' || x.sub === 'sending');
194
+ if (x.state !== 'done' && !busyAgent) acts.push(button({ label: COPY.back, variant: 'ghost', size: 'sm', icon: 'undo-2', disabled: inst.busy, attrs: { 'data-my-work-act': 'reopen' }, onClick: () => act(inst, { action: 'reopen', item: x.id }, COPY.reopened) }));
195
+ if (acts.length) kids.push(el('div', { class: 'rp-mywork-acts' }, acts));
196
+ return el('div', { class: 'rp-mywork-status' }, kids);
197
+ }
198
+
199
+ function itemPane(inst, d) {
200
+ const x = d.items.find((y) => y.id === inst.open);
201
+ if (!x) return el('div', { class: 'rp-mywork-page' }, el('p', { class: 'rp-section__empty' }, COPY.pick));
202
+ const head = el('header', { class: 'rp-mywork-who' },
203
+ el('h3', { class: 'rp-mywork-who__title' }, x.title),
204
+ el('p', { class: 'rp-mywork-hint' }, [x.source === 'jira' ? 'Jira' : 'WBS', x.key, x.repo, x.status, x.due ? COPY.due(x.due) : null, x.from ? COPY.from(x.from) : null].filter(Boolean).join(' · ')),
205
+ x.url ? el('a', { class: 'rp-mywork-link', href: x.url, target: '_blank', rel: 'noopener noreferrer' }, icon('link'), ' ', COPY.source) : null);
206
+ const details = button({ label: inst.details ? COPY.hide : COPY.details, variant: 'ghost', size: 'sm', iconEnd: inst.details ? 'chevron-down' : 'chevron-right',
207
+ attrs: { 'aria-expanded': inst.details ? 'true' : 'false', 'data-my-work-details': '' }, onClick: () => { inst.details = !inst.details; inst.last = ''; draw(inst); } });
208
+ return el('article', { class: 'rp-mywork-page', 'data-my-work-page': x.id },
209
+ head, foundSec(x),
210
+ x.state === 'brief' ? section(COPY.instruction, [briefSec(inst, x)], { 'data-my-work-sec': 'brief' }) : statusSec(inst, x),
211
+ el('div', { class: 'rp-mywork-acts' }, details), inst.details ? el('p', { class: 'rp-mywork-hint', 'data-my-work-score': '' }, COPY.score(x.wsjf)) : null);
212
+ }
213
+
214
+ /* ── the page ────────────────────────────────────────────────────────────────────────────────────────────────────── */
215
+
216
+ function header(inst) {
217
+ if (inst.head) return inst.head;
218
+ inst.readLine = el('p', { class: 'rp-mywork-hint', 'data-my-work-read': '' });
219
+ inst.head = el('div', { class: 'rp-mywork-top' }, el('h2', { class: 'rp-head__title' }, COPY.title), inst.readLine,
220
+ button({ label: COPY.readAgain, variant: 'outline', size: 'sm', icon: 'refresh-cw', attrs: { 'data-my-work-act': 'refresh' }, onClick: () => readAgain(inst) }));
221
+ return inst.head;
222
+ }
223
+
224
+ function draw(inst) {
225
+ const root = inst.root;
226
+ const d = myWork();
227
+ const head = header(inst);
228
+ if (head.parentNode !== root || inst.body.parentNode !== root) root.replaceChildren(head, inst.body);
229
+ const fp = JSON.stringify([d, inst.tab, inst.open, inst.details, inst.busy]);
230
+ if (fp === inst.last) return;
231
+ // Focus stays in an instruction being typed: the redraw waits until the field is left.
232
+ const typing = document.activeElement && inst.body.contains(document.activeElement) && document.activeElement.matches('textarea');
233
+ if (typing) { inst.last = ''; return; }
234
+ inst.last = fp;
235
+ if (!d) { inst.body.replaceChildren(el('p', { class: 'rp-section__empty' }, COPY.loading)); return; }
236
+ if (!d.ok) { inst.body.replaceChildren(el('p', { class: 'rp-section__empty rp-section__empty--bad', role: 'alert' }, d.error || COPY.readError)); return; }
237
+ inst.readLine.textContent = COPY.readAt(d.read_at ? new Date(d.read_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : null);
238
+ const shown = d.items.filter((x) => TAB_STATES[inst.tab].includes(x.state));
239
+ if (!shown.some((x) => x.id === inst.open)) inst.open = shown.length ? shown[0].id : null;
240
+ const banner = (d.banner || []).length ? el('p', { class: 'rp-mywork-hint', role: 'status', 'data-my-work-banner': '' }, d.banner.map((b) => b.source + ': ' + b.error).join(' · ')) : null;
241
+ inst.body.replaceChildren(...[banner, el('div', { class: 'rp-mywork-grid' }, listPane(inst, d), itemPane(inst, d))].filter(Boolean));
242
+ }
@@ -23,6 +23,7 @@ export const COPY = {
23
23
  nav: {
24
24
  label: 'Destinations',
25
25
  today: 'Agenda',
26
+ mywork: 'My work',
26
27
  initiatives: 'Initiatives',
27
28
  board: 'Board',
28
29
  reports: 'Reports',
@@ -33,6 +34,8 @@ export const COPY = {
33
34
  },
34
35
  /** The Today count on the top bar and on the phone tab, for a screen reader. */
35
36
  needs: (n) => (n === 1 ? '1 needs you' : n + ' need you'),
37
+ /** My work's count on the top bar, for a screen reader (initiative my-work). */
38
+ toBrief: (n) => (n === 1 ? '1 item to brief' : n + ' items to brief'),
36
39
  search: 'Search or capture',
37
40
  searchKey: '⌘K',
38
41
  sync: {
@@ -0,0 +1,51 @@
1
+ /* pm/my-work-card.js — Agenda's one card for the items that wait for a brief (initiative my-work, PRD R8).
2
+ *
3
+ * One record, two lenses (the PM's choice, 2026-10-07): My work owns the assigned backlog and the brief, and Agenda
4
+ * shows ONE card, "N items need your brief", when N is 1 or more. Its button opens My work. The card counts once in
5
+ * Agenda's count, whichever repositories Agenda shows: the list is the PM's, not a repository's.
6
+ *
7
+ * Loaded at boot by registry.js loadExtensions. It reads the list once, then again every 30 seconds and whenever the
8
+ * window gets the focus back, so the bar's count and this card stay current.
9
+ */
10
+
11
+ import { registerTodaySource, openDestination } from './registry.js';
12
+ import { setState } from './state.js';
13
+ import { todayCard } from './today-cards.js';
14
+ import { myWork, loadMyWork, onMyWork } from './my-work-data.js';
15
+
16
+ export const COPY = Object.freeze({
17
+ cause: (n) => (n === 1 ? '1 item needs your brief' : `${n} items need your brief`),
18
+ evidence: (titles) => 'Top: ' + titles.join(' · '),
19
+ act: 'Open My work',
20
+ cli: 'pmos my-work list',
21
+ });
22
+
23
+ const brief = () => { const d = myWork(); return d && d.ok ? d.counts.brief : 0; };
24
+
25
+ registerTodaySource({
26
+ id: 'my-work-brief',
27
+ kind: 6, // capture: the agent read new assigned work and the PM triages it. It ranks after the gates, sessions and Keep cards
28
+ rank: 40,
29
+ count: () => (brief() > 0 ? 1 : 0),
30
+ cards: () => {
31
+ const n = brief();
32
+ if (!n) return [];
33
+ const top = myWork().items.filter((x) => x.state === 'brief').slice(0, 2).map((x) => x.key + ' ' + x.title);
34
+ return [todayCard({
35
+ kind: 6, id: 'my-work|brief', cause: COPY.cause(n), evidence: COPY.evidence(top), act: { label: COPY.act, cli: COPY.cli },
36
+ onAct: () => openDestination('mywork'), attrs: { 'data-my-work-card': String(n) },
37
+ })];
38
+ },
39
+ });
40
+
41
+ if (typeof window !== 'undefined') {
42
+ // re-render Agenda only when the card would change: a re-render on every read would take the focus off a card the PM is on
43
+ let shown = '';
44
+ onMyWork((d) => {
45
+ const sig = d && d.ok ? d.counts.brief + '|' + d.items.filter((x) => x.state === 'brief').slice(0, 2).map((x) => x.id).join(',') : '0';
46
+ if (sig !== shown) { shown = sig; setState({}); }
47
+ });
48
+ loadMyWork(true);
49
+ setInterval(() => loadMyWork(), 30000);
50
+ window.addEventListener('focus', () => loadMyWork());
51
+ }
@@ -0,0 +1,36 @@
1
+ /* pm/my-work-data.js — the one read of GET /api/my-work that the page shares (initiative my-work, PRD R8, R10).
2
+ *
3
+ * My work, the Agenda card and the top bar's count all read the same list, once: a second reader inside READ_MS gets
4
+ * the answer already held. Every reader is told when a new answer arrives (onMyWork), and the top bar hears it as the
5
+ * window event `pmos:mywork` with { brief } (the count of items to brief), so the bar needs no import of this module.
6
+ * In fixture mode nothing loads it.
7
+ */
8
+
9
+ import { pmGet } from './http.js';
10
+
11
+ const READ_MS = 30000;
12
+ let data = null;
13
+ let at = 0;
14
+ let loading = null;
15
+ const subs = new Set();
16
+
17
+ /** The last answer, or null before the first one. */
18
+ export const myWork = () => data;
19
+
20
+ /** Read the list again when the last answer is older than READ_MS, or always with `force`. Resolves to the answer. */
21
+ export function loadMyWork(force) {
22
+ if (loading) return loading;
23
+ if (!force && data && Date.now() - at < READ_MS) return Promise.resolve(data);
24
+ loading = pmGet('/api/my-work').then((j) => {
25
+ loading = null;
26
+ at = Date.now();
27
+ data = j && j.ok ? j : { ok: false, error: (j && j.error) || 'My work could not be read.', items: [], counts: { brief: 0, agents: 0, ready: 0, yours: 0, done: 0, all: 0 }, banner: [] };
28
+ for (const fn of subs) { try { fn(data); } catch (e) { console.error('[pm] my-work reader failed:', e); } }
29
+ if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent('pmos:mywork', { detail: { brief: data.ok ? data.counts.brief : 0 } }));
30
+ return data;
31
+ });
32
+ return loading;
33
+ }
34
+
35
+ /** Call `fn(answer)` on each new answer. Returns the function that stops it. */
36
+ export function onMyWork(fn) { subs.add(fn); return () => subs.delete(fn); }
@@ -45,6 +45,8 @@ import { empty, ensureComponentsCss } from './primitives.js';
45
45
 
46
46
  export const DESTINATIONS = Object.freeze([
47
47
  Object.freeze({ id: 'today', label: 'Agenda', key: 'g t', module: 'dest-today.js', run: 3 }),
48
+ // Not a pm-os-absorb run (run: null): initiative my-work added it — the work assigned to the PM, ranked and briefed.
49
+ Object.freeze({ id: 'mywork', label: 'My work', key: 'g w', module: 'dest-mywork.js', run: null }),
48
50
  Object.freeze({ id: 'initiatives', label: 'Initiatives', key: 'g i', module: 'dest-initiatives.js', run: 2 }),
49
51
  Object.freeze({ id: 'board', label: 'Board', key: 'g b', module: 'dest-board.js', run: 4 }),
50
52
  Object.freeze({ id: 'reports', label: 'Reports', key: 'g r', module: 'dest-reports.js', run: 5 }),
@@ -58,6 +60,7 @@ export const EXTENSIONS = Object.freeze([
58
60
  'provider-cards.js', // the base: every needs provider's rows as Today cards (Run 3, 3B, owns it after)
59
61
  'bd-timeline.js', // Run 4 (4B): Breakdown › Timeline
60
62
  'initiatives-timeline.js', // Run 4 (4C): Initiatives › Timeline
63
+ 'my-work-card.js', // initiative my-work: Agenda's one card for the items that need a brief
61
64
  ]);
62
65
 
63
66
  const loaded = {}; // module file name -> the module