create-pmos 0.212.0 → 0.214.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.212.0",
3
+ "version": "0.214.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@316d11d
1
+ kernel-source: pmos@e529926
2
2
  assembled: 2026-10-14
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-14T02:50:31Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T03:12:52Z"}
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-14T02:50:31Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T03:12:52Z"}
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-14T02:50:31Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T03:12:52Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -43,13 +43,32 @@ const { harness } = require('./c3-ini-harness');
43
43
  await open('board:idpf:sprints');
44
44
  await until(() => st(() => !!document.querySelector('[data-inb="sprints-source"]')));
45
45
  ok('SP1 the active sprint header says Jira, read-only, and there is no banner', /Jira, read-only/.test(await text('[data-inb="sprints-source"]')) && await st(() => !!document.querySelector('[data-inb-state="active"] [data-inb="sprints-source"]') && !document.querySelector('.fold [data-inb="sprints-source"], .fold[data-inb="sprints-source"]')));
46
- ok('SP2 the active sprint is drawn with its counts; future and closed sprints are listed', await st(() => !!document.querySelector('[data-inb-state="active"]') && !!document.querySelector('[data-inb-state="future"]') && !!document.querySelector('[data-inb-state="closed"]') && ((m) => !!m && Number(m[1]) === 12 && Number(m[2]) + Number(m[3]) + Number(m[4]) === 12)(/Items(\d+):(\d+)done,(\d+)inprogress,(\d+)todo/.exec(document.querySelector('[data-inb="sprint-counts"]').textContent.replace(/\s+/g, '')))));
46
+ ok('SP2 the active sprint is drawn with its counts; the future sprint is listed; closed sprints are kept off the page', await st(() => !!document.querySelector('[data-inb-state="active"]') && !!document.querySelector('[data-inb-state="future"]') && !document.querySelector('[data-inb-state="closed"]') && !!document.querySelector('[data-inb="sprints-past"] [data-inb-past]') && ((m) => !!m && Number(m[1]) === 12 && Number(m[2]) + Number(m[3]) + Number(m[4]) === 12)(/Items(\d+):(\d+)done,(\d+)inprogress,(\d+)todo/.exec(document.querySelector('[data-inb="sprint-counts"]').textContent.replace(/\s+/g, '')))));
47
47
  // sprint-goal-summary: the goal (from Jira, or written from the tickets and why), the team summary, closed sprints read once
48
48
  ok('SP4 the sprint goal is written from the tickets, with Jira\'s goal only as a reference', await st(() => { const g = document.querySelector('[data-inb="sprint-summary"] [data-inb="sprint-goal"]'); return !!g && g.dataset.inbGoal === 'tickets' && /SPRINT GOAL · from the tickets/.test(g.textContent) && /In Jira: “Q4 delivery”/.test(g.textContent) && !/from Jira/.test(g.textContent); })
49
- && /from the tickets/.test(await text('[data-inb-state="future"] [data-inb="sprint-goal"]')) && !(await text('[data-inb-state="future"] [data-inb="sprint-goal"]')).includes('In Jira') && /from the tickets/.test(await text('[data-inb-state="closed"] [data-inb="sprint-goal"]')));
49
+ && /from the tickets/.test(await text('[data-inb-state="future"] [data-inb="sprint-goal"]')) && !(await text('[data-inb-state="future"] [data-inb="sprint-goal"]')).includes('In Jira'));
50
50
  ok('SP5 what the team is working on: a row per theme, one with no initiative', await st(() => document.querySelectorAll('[data-inb="sprint-themes"] [data-inb-theme]').length === 2 && !!document.querySelector('[data-inb-theme="none"]') && /No initiative/.test(document.querySelector('[data-inb-theme="none"]').textContent)));
51
- ok('SP6 a closed sprint shows done of total, carried, and that it was read and summarised once', /5 of 7 done/.test(await text('[data-inb-state="closed"]')) && /2 carried to/.test(await text('[data-inb-state="closed"]'))
52
- && /Read and summarised once on 29 Sep\. PMOS does not read a closed sprint again\./.test(await text('[data-inb-state="closed"] [data-inb="sprint-read-once"]')) && /Closed sprints/.test(await text('[data-inb="sprints-how"]')));
51
+ // the sprint brief (mockup-c3-sprint-brief v3): where it stands, issues with a proposed plan, done, flow, asks; velocity; tickets behind a press
52
+ ok('SP7 the brief says where the sprint stands, with the day and the points', await st(() => { const v = document.querySelector('[data-inb="sprint-verdict"]'); return !!v && v.dataset.inbHealth === 'warn' && /At risk/.test(v.textContent) && /Day 7 of 14/.test(v.textContent) && /21 of 48 points/.test(v.textContent); })
53
+ && /Needs attention/.test(await text('[data-inb="sprint-summary"] .sh2')));
54
+ ok('SP8 each issue to resolve carries a proposed plan, when it is done, and Add to my Work', await st(() => { const r = [...document.querySelectorAll('[data-inb="sprint-issues"] [data-inb-sp-issue]')]; const b = r.find((x) => x.dataset.inbSpIssue === 'bad');
55
+ return r.length === 2 && !!b && /Blocked/.test(b.textContent) && b.querySelectorAll('[data-inb="sprint-plan"] ol li').length === 3 && /Done when: /.test(b.textContent) && !!b.querySelector('[data-inb-sp-addwork]'); }));
56
+ await page.click('[data-inb="sprint-issues"] [data-inb-sp-issue="bad"] [data-inb-sp-addwork]');
57
+ ok('SP9 Add to my Work files the issue in the Work inbox', await until(() => st(() => /Added to your Work inbox/.test(document.body.textContent))));
58
+ ok('SP10 completed work says what is not in Prod yet; the flow and the asks are drawn', await st(() => document.querySelectorAll('[data-inb="sprint-done"] [data-inb-sp-done]').length === 2 && /Not in Prod yet/.test(document.querySelector('[data-inb-sp-done="waiting"]').textContent)
59
+ && document.querySelectorAll('[data-inb="sprint-flow"] u').length >= 2 && document.querySelectorAll('[data-inb="sprint-asks"] li').length === 2));
60
+ ok('SP11 the velocity card draws the last four sprints and this one, with the average', await st(() => { const c = document.querySelector('[data-inb="sprint-velocity"]'); return !!c && c.querySelectorAll('svg.ini-vel rect.done').length === 5 && !!c.querySelector('line.avg') && /Average 27 points done a sprint, 77% of what was committed/.test(c.textContent); })
61
+ && /about 36 of 48 points/.test(await text('[data-inb="sprint-counts"]')));
62
+ ok('SP12 the tickets are hidden until Show all is pressed', await st(() => document.querySelector('[data-inb="sprint-ticket-list"]').hidden && /Show all 12 tickets/.test(document.querySelector('[data-inb="sprint-show-tickets"]').textContent)));
63
+ await page.click('[data-inb="sprint-show-tickets"]');
64
+ ok('SP13 Show all opens the list of every ticket', await until(() => st(() => !document.querySelector('[data-inb="sprint-ticket-list"]').hidden && document.querySelectorAll('[data-inb="sprint-ticket-list"] [data-inb-issue]').length === 12 && /Hide 12 tickets/.test(document.querySelector('[data-inb="sprint-show-tickets"]').textContent))));
65
+ await page.click('[data-inb="sprints-past-open"]');
66
+ await until(() => st(() => !!document.querySelector('[data-inb="sprints-past-sheet"] [data-inb-state="closed"]')));
67
+ const past = '[data-inb="sprints-past-sheet"] [data-inb-state="closed"]';
68
+ ok('SP6 a past sprint opens in a sheet: goal met, done of total, carried, what got in the way, read once', /5 of 7 done/.test(await text(past)) && /2 carried to/.test(await text(past)) && /Goal met/.test(await text(past))
69
+ && /from the tickets/.test(await text(past + ' [data-inb="sprint-goal"]')) && /waited 5 days on design/.test(await text(past + ' [data-inb="sprint-got"]'))
70
+ && /Read and summarised once on 29 Sep\. PMOS does not read a closed sprint again\./.test(await text(past + ' [data-inb="sprint-read-once"]')) && /Closed sprints/.test(await text('[data-inb="sprints-how"]')));
71
+ await page.keyboard.press('Escape');
53
72
  ok('SP3 the sprint burndown card draws the ideal and actual lines', await until(() => st(() => !!document.querySelector('[data-ini-tool-card="burndown"] svg.ini-burn path.ideal') && !!document.querySelector('[data-ini-tool-card="burndown"] svg.ini-burn path.act'))));
54
73
 
55
74
  /* ── an initiative's six tabs ── */
@@ -155,7 +155,7 @@ const { harness } = require('./c3-ini-harness');
155
155
  ok('P1 an on-hold initiative: the banner with the reason and the check-back day, Resume and Cancel', /On hold since/.test(ban) && /Q1 budget/.test(ban) && /Check back/.test(ban) && /Resume/.test(ban), ban);
156
156
  await page.click('[data-il-menu="IDPF-152"]');
157
157
  const pm = await st(() => [...document.querySelectorAll('[data-ini="menu"] .ini-mlbl')].map((n) => n.textContent).join(','));
158
- ok('P2 the page "…" menu holds Status and Tidy up', pm === 'Status,Tidy up' && await st(() => !!document.querySelector('[data-ini="menu"] [data-il-resume]')), pm);
158
+ ok('P2 the page "…" menu holds This initiative, Status and Tidy up', pm === 'This initiative,Status,Tidy up' && await st(() => !!document.querySelector('[data-ini="menu"] [data-il-resume]')), pm);
159
159
  await page.keyboard.press('Escape');
160
160
  await page.click('[data-il="banner"] [data-il-resume]');
161
161
  await until(async () => ((await row('IDPF-152')) || {}).status === 'on track');
@@ -121,11 +121,15 @@ const { harness } = require('./c3-ini-harness');
121
121
 
122
122
  /* ── an initiative: tools, stepper, workflow editor ── */
123
123
  await open('ini:idpf:IDPF-07:overview');
124
- const strip = await st(() => [...document.querySelectorAll('[data-ini="tools"] [data-ini-tool]')].map((b) => b.dataset.iniTool).join(','));
125
- ok('I1 the tools strip shows the tools of the current stage (Plan: RICE, PRD, Done-criteria, ...)', /^rice,prd,rubric/.test(strip), strip);
126
- await page.click('[data-ini="more-tools"]');
127
- const more = await until(() => st(() => document.querySelectorAll('[data-ini="menu"] [data-ini-moretool]').length));
128
- ok('I2 More tools lists the tools of the other stages, grouped by stage', more > 3 && /Discover/.test(await st(() => document.querySelector('[data-ini="menu"]').textContent)), String(more));
124
+ const head = await st(() => { const p = document.querySelector('[data-ini-page]'); const c = document.querySelector('[data-ini="crumbs"]').parentElement;
125
+ return { title: !!p.querySelector(':scope > .lh'), dash: [...c.querySelectorAll('button')].some((b) => b.textContent.trim() === 'Dashboard'), tag: /Jira not set|Jira · |PMOS tasks only/.test(p.textContent.split('Overview')[0]),
126
+ status: !!c.querySelector('[data-ini="ini-chips"]'), gear: !!document.querySelector('[data-ini="stepper"]').parentElement.querySelector('[data-ini="edit-workflow"]') }; });
127
+ ok('H1 the header is the breadcrumb row with the status, and the stepper row with the gear; no title row, Dashboard button or Jira tag', !head.title && !head.dash && !head.tag && head.status && head.gear, JSON.stringify(head));
128
+ await page.click('[data-ini="tools"]');
129
+ const strip = await until(() => st(() => { const m = document.querySelector('[data-ini="menu"]'); return m && [...m.querySelectorAll('[data-ini-tool]')].map((b) => b.dataset.iniTool).join(','); }));
130
+ ok('I1 Tools lists the tools of the current stage first (Plan: RICE, PRD, Done-criteria, ...)', /^rice,prd,rubric/.test(strip) && (await st(() => document.querySelector('[data-ini="menu"] .ini-mlbl').textContent)) === 'For Plan', strip);
131
+ const more = await st(() => document.querySelectorAll('[data-ini="menu"] [data-ini-moretool]').length);
132
+ ok('I2 then the tools of the other stages, grouped by stage', more > 3 && /Discover/.test(await st(() => document.querySelector('[data-ini="menu"]').textContent)), String(more));
129
133
  await page.keyboard.press('Escape');
130
134
  ok('I3 Escape closes the menu', await until(() => st(() => !document.querySelector('[data-ini="menu"]'))));
131
135
  await page.click('[data-ini-step="Build & Deliver"]');
@@ -150,8 +154,9 @@ const { harness } = require('./c3-ini-harness');
150
154
  ok('W4 the initiative now runs Research only and moved to Plan; other initiatives keep theirs', r07.stage === 'Plan' && r07.wf === 'Discover,Plan,Evaluate'
151
155
  && await store(() => !window.__c3boards.rows.find((r) => r.id === 'IDPF-12').template), JSON.stringify(r07));
152
156
 
153
- /* ── Jira epics of an initiative (jira-epic-items) ── */
154
- await page.click('[data-ini="jira-epics"]');
157
+ /* ── Jira epics of an initiative (jira-epic-items): in the page "…" menu ── */
158
+ await page.click('[data-il-menu="IDPF-07"]');
159
+ await page.click('[data-ini="menu"] [data-ini="jira-epics"]');
155
160
  await until(() => st(() => !!document.querySelector('[data-ini="epics-input"]')));
156
161
  await page.fill('[data-ini="epics-input"]', 'IDPF-900, XX-1');
157
162
  await page.click('[data-ini="epics-save"]');
@@ -160,8 +165,11 @@ const { harness } = require('./c3-ini-harness');
160
165
  await page.fill('[data-ini="epics-input"]', 'idpf-900, idpf-901');
161
166
  await page.click('[data-ini="epics-save"]');
162
167
  await until(() => store(() => (window.__c3boards.rows.find((r) => r.id === 'IDPF-07').epics || []).length === 2));
163
- const epChip = await until(() => st(() => { const c = document.querySelector('[data-ini="jira-epics"]'); return c && /Jira epics IDPF-900, IDPF-901/.test(c.textContent) && c.textContent; }));
164
- ok('E2 the epics are saved upper-cased, and the initiative header names them', !!epChip, epChip);
168
+ await until(() => st(() => !document.querySelector('[data-ini="epics-input"]')));
169
+ await page.click('[data-il-menu="IDPF-07"]');
170
+ const epChip = await until(() => st(() => { const c = document.querySelector('[data-ini="menu"] [data-ini="jira-epics"]'); return c && /Jira epics IDPF-900, IDPF-901/.test(c.textContent) && c.textContent; }));
171
+ ok('E2 the epics are saved upper-cased, and the page menu names them', !!epChip, epChip);
172
+ await page.keyboard.press('Escape');
165
173
 
166
174
  /* ── phone width ── */
167
175
  await page.setViewportSize({ width: 400, height: 860 });
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Initiatives on the console: Portfolio, boards and workflows"
4
4
  description: "How to use the Initiatives tab of the console page (/): the Portfolio of boards, a board and its nine tabs, an initiative with its own workflow and stage records, and the Build and deliver setting."
5
- timestamp: 2026-10-13
5
+ timestamp: 2026-10-14
6
6
  ---
7
7
 
8
8
  # Initiatives on the console: Portfolio, boards and workflows
@@ -86,9 +86,9 @@ Press the gear in the board header to open the board settings.
86
86
 
87
87
  ## An initiative
88
88
 
89
- Each initiative has its own workflow, picked from a template: for example Product, Quick fix, Research only, or Content and launch. The stepper shows the stages of that workflow. Press a stage to move the initiative there. The move is your decision, so nothing blocks it. An order rule shows as a warning chip, for example "Skipped Plan" or "No spec yet". Press **Workflow** to change the template or edit the stages. When you remove a stage that holds the initiative, the editor asks where it goes.
89
+ Each initiative has its own workflow, picked from a template: for example Product, Quick fix, Research only, or Content and launch. The stepper shows the stages of that workflow. Press a stage to move the initiative there. The move is your decision, so nothing blocks it. An order rule shows as a warning chip next to the status, at the right of the breadcrumb, for example "Skipped Plan" or "No spec yet". Press the gear next to the stepper to change the template or edit the stages. When you remove a stage that holds the initiative, the editor asks where it goes.
90
90
 
91
- Under the stepper are the tools of the current stage. **More tools** lists the tools of the other stages. The tabs are **Overview**, **Breakdown**, **Docs**, **Tickets**, **Timeline** and **History**.
91
+ Press **Tools**, at the right of the stepper, to open a tool. The menu lists the tools of the current stage first, then the tools of the other stages. The **…** menu at the right of the breadcrumb opens the Dashboard and the Jira epics of the initiative, and holds Status and Tidy up. The tabs are **Overview**, **Breakdown**, **Docs**, **Tickets**, **Timeline** and **History**.
92
92
 
93
93
  The **Overview** shows the stage records of the current stage:
94
94
 
@@ -126,7 +126,19 @@ The check then writes a goal and a short summary for each sprint, from the ticke
126
126
  - The check always writes the goal from the sprint's tickets, because Jira's sprint goal is often empty, too broad (for example "Q4 delivery") or incomplete. The tab shows it as **from the tickets**.
127
127
  - When Jira has a sprint goal, the tab shows it under the goal as a reference: **In Jira: "…"**.
128
128
  - The active and the next sprint are summarised again at each check.
129
- - A closed sprint is read and summarised once. After that, the check does not search it again and keeps its first summary.
129
+ - A closed sprint is read and summarised once. After that, the check does not search it again and keeps its first summary. The check also says if the goal was met and what got in the way.
130
+
131
+ ## The sprint brief
132
+
133
+ The active sprint opens with a brief for the PM, not a list of tickets:
134
+
135
+ - **Where the sprint stands**: on track, at risk or off track, about how much will carry over, and which work is exposed.
136
+ - **Issues to resolve**: each blocked or stuck ticket, why it matters, and a proposed plan to move it to done: who to ask, the decision to make and the fallback. **Add to my Work** puts the issue in your Work inbox. Nothing is written to Jira.
137
+ - **Completed so far**: the outcomes done in the sprint. Work that is done but not yet in production says **Not in Prod yet**.
138
+ - **Where the tickets are**: one bar for the sprint's tickets, by Jira status.
139
+ - **For you**: the decisions only you can take this week.
140
+
141
+ PMOS counts the numbers itself from the Jira read: the points done and committed, the tickets with no estimate, the velocity of the last four closed sprints and the forecast. The check writes the words. The sprint's tickets stay behind **Show all**. Closed sprints are under **Past sprints** on the right, and each one opens in a sheet.
130
142
 
131
143
  The check writes `<state>/tracker/jira/_sprint-notes.md` with:
132
144
 
@@ -15,7 +15,8 @@
15
15
  * filterRows(rows, query) → { total, offset, limit, rows, facets }
16
16
  * health(rows) → 'ok' | 'warn' | 'bad'
17
17
  * counts(rows, workflow) → { total, open, byCat, byStage, byStatus, risk, late }
18
- * sprintsOf(snapshots, onBoard, whole?, notes?) → { source, readAt, sprints } (notes: each sprint's goal and summary, sprint-goal-summary)
18
+ * sprintsOf(snapshots, onBoard, whole?, notes?, { today }?) → { source, readAt, sprints, velocity } (notes: each sprint's goal, summary
19
+ * and brief; points, flow, velocity and the active sprint's pace are counted here, sprint-goal-summary)
19
20
  * INI_TEMPLATES · isBuildStage · templateBuilds · boardSettings(board) the per-initiative workflows (console3-initiatives-parity)
20
21
  * workflowOf(board, id, folded) → the stages an initiative runs: its own (ini-workflow), else the board's
21
22
  * applyIniWorkflow(board, wfNow, stage, template, stages, moveTo) → { ok, template, workflow, moved } | { ok:false, status, error }
@@ -582,7 +583,24 @@ function counts(rows, workflow) {
582
583
  * lists EVERY issue Jira holds in it, whoever it is assigned to; an issue no initiative of the board holds has initiative null
583
584
  * (jira-sprint-scope). Without it, a sprint lists only the board's own tickets.
584
585
  */
585
- function sprintsOf(snapshots, onBoard = () => true, whole = null, notes = null) {
586
+ const ptsOf = (t) => (typeof t.points === 'number' && Number.isFinite(t.points) && t.points >= 0 ? t.points : null);
587
+ const DAY_MS = 864e5;
588
+ const dayNum = (iso) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || '')); return m ? Date.UTC(+m[1], +m[2] - 1, +m[3]) / DAY_MS : null; };
589
+ /** A sprint's points from its tickets: committed (every estimated ticket), done, and how many tickets have no estimate. */
590
+ function pointsOf(issues) {
591
+ const est = issues.filter((i) => i.points != null);
592
+ if (!est.length) return null;
593
+ const sum = (a) => Math.round(a.reduce((x, i) => x + i.points, 0) * 10) / 10;
594
+ return { committed: sum(est), done: sum(est.filter((i) => i.category === 'done')), unestimated: issues.length - est.length };
595
+ }
596
+ /** Where the tickets are: one entry per Jira status, to do first, then in progress, then done. */
597
+ function flowOf(issues) {
598
+ const by = new Map();
599
+ for (const i of issues) { const k = i.status || i.category; if (!by.has(k)) by.set(k, { status: k, category: i.category, n: 0 }); by.get(k).n++; }
600
+ const rank = { todo: 0, doing: 1, done: 2 };
601
+ return [...by.values()].sort((a, b) => (rank[a.category] - rank[b.category]) || (b.n - a.n));
602
+ }
603
+ function sprintsOf(snapshots, onBoard = () => true, whole = null, notes = null, opts = {}) {
586
604
  const byName = new Map();
587
605
  let readAt = null;
588
606
  let any = false;
@@ -599,7 +617,7 @@ function sprintsOf(snapshots, onBoard = () => true, whole = null, notes = null)
599
617
  if (!sp.start && typeof t.sprint.start === 'string' && t.sprint.start) sp.start = t.sprint.start; // the sprint's dates, as Jira holds them
600
618
  if (!sp.end && typeof t.sprint.end === 'string' && t.sprint.end) sp.end = t.sprint.end;
601
619
  const category = ['todo', 'doing', 'done'].includes(t.category) ? t.category : 'todo';
602
- sp.issues.push({ key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: s.initiative, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null });
620
+ sp.issues.push({ key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: s.initiative, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null, points: ptsOf(t) });
603
621
  sp.counts[category]++;
604
622
  }
605
623
  }
@@ -617,7 +635,7 @@ function sprintsOf(snapshots, onBoard = () => true, whole = null, notes = null)
617
635
  ...all.map((t) => {
618
636
  const category = ['todo', 'doing', 'done'].includes(t.category) ? t.category : 'todo';
619
637
  const o = own.get(t.key);
620
- return { key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: o ? o.initiative : null, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null };
638
+ return { key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: o ? o.initiative : null, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null, points: ptsOf(t) };
621
639
  }),
622
640
  ...sp.issues.filter((i) => !keys.has(i.key)), // a board ticket the sprint read has not seen yet keeps its own reading
623
641
  ];
@@ -640,7 +658,7 @@ function sprintsOf(snapshots, onBoard = () => true, whole = null, notes = null)
640
658
  if (!(n.themes || []).some((t) => t && t.initiative && onBoard(t.initiative))) continue;
641
659
  const issues = wt.filter((t) => t && t.key && t.sprint && same(t.sprint, n)).map((t) => {
642
660
  const category = ['todo', 'doing', 'done'].includes(t.category) ? t.category : 'todo';
643
- return { key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: iniOf.get(t.key) || null, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null };
661
+ return { key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: iniOf.get(t.key) || null, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null, points: ptsOf(t) };
644
662
  });
645
663
  const counts = { todo: 0, doing: 0, done: 0 };
646
664
  for (const i of issues) counts[i.category]++;
@@ -663,16 +681,42 @@ function sprintsOf(snapshots, onBoard = () => true, whole = null, notes = null)
663
681
  sp.summarisedTickets = Number.isInteger(n.tickets) ? n.tickets : null;
664
682
  sp.closed = sp.state === 'closed' && n.closed ? { ...n.closed } : null;
665
683
  sp.frozen = sp.state === 'closed' && n.state === 'closed';
684
+ sp.brief = sp.state !== 'closed' && n.brief ? n.brief : null;
685
+ sp.hit = sp.state === 'closed' && n.hit ? n.hit : null;
686
+ sp.got = sp.state === 'closed' && n.got ? n.got : null;
666
687
  } else {
667
688
  sp.goal = null;
668
689
  sp.jiraGoal = jg;
669
- sp.summary = null; sp.themes = []; sp.writtenAt = null; sp.summarisedTickets = null; sp.closed = null; sp.frozen = false;
690
+ sp.summary = null; sp.themes = []; sp.writtenAt = null; sp.summarisedTickets = null; sp.closed = null; sp.frozen = false; sp.brief = null; sp.hit = null; sp.got = null;
670
691
  }
671
692
  }
672
693
  const order = { active: 0, future: 1, closed: 2 };
673
694
  const sprints = [...byName.values()].sort((a, b) => ((order[a.state] ?? 3) - (order[b.state] ?? 3)) || a.name.localeCompare(b.name));
674
695
  for (const sp of sprints) sp.issues.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }));
675
- return { source: any ? 'jira' : 'none', readAt, sprints };
696
+ // Counted by PMOS, never written by the agent: each sprint's points and status flow, the velocity of the last four closed
697
+ // sprints, and the active sprint's pace and forecast (the sprint brief).
698
+ for (const sp of sprints) { sp.points = pointsOf(sp.issues); sp.flow = flowOf(sp.issues); }
699
+ const velocity = sprints.filter((sp) => sp.state === 'closed').map((sp) => {
700
+ let p = sp.points ? { committed: sp.points.committed, done: sp.points.done } : null;
701
+ const m = !p && sp.closed && /(\d+(?:\.\d+)?)\s*of\s*(\d+(?:\.\d+)?)/.exec(String(sp.closed.points || ''));
702
+ if (m) p = { done: Number(m[1]), committed: Number(m[2]) };
703
+ return p ? { name: sp.name, end: sp.end || '', ...p } : null;
704
+ }).filter(Boolean).sort((a, b) => (a.end || '').localeCompare(b.end || '') || a.name.localeCompare(b.name, undefined, { numeric: true })).slice(-4);
705
+ const active = sprints.find((sp) => sp.state === 'active');
706
+ if (active) {
707
+ const today = dayNum(opts.today) ?? Math.floor(Date.now() / DAY_MS);
708
+ const a = dayNum(active.start); const z = dayNum(active.end);
709
+ const pace = { day: null, days: null, daysLeft: null, avg: null, hitRate: null, forecast: null };
710
+ if (a != null && z != null && z > a) { pace.days = z - a; pace.day = Math.min(pace.days, Math.max(0, today - a + 1)); pace.daysLeft = Math.max(0, z - today); }
711
+ if (velocity.length) {
712
+ const done = velocity.reduce((x, v) => x + v.done, 0); const com = velocity.reduce((x, v) => x + v.committed, 0);
713
+ pace.avg = Math.round(done / velocity.length * 10) / 10;
714
+ pace.hitRate = com ? Math.round(done / com * 100) / 100 : null;
715
+ if (active.points && pace.days) pace.forecast = Math.min(active.points.committed, Math.round(active.points.done + pace.avg * pace.daysLeft / pace.days));
716
+ }
717
+ active.pace = pace;
718
+ }
719
+ return { source: any ? 'jira' : 'none', readAt, sprints, velocity };
676
720
  }
677
721
 
678
722
  module.exports = {
@@ -355,6 +355,38 @@ function readSprintNotes(root) {
355
355
  function frozenNotes(root) { const r = readSprintNotes(root); return r ? r.notes.filter((x) => x.state === 'closed') : []; }
356
356
  const count = (v, max) => (Number.isInteger(v) && v >= 0 && v <= (max == null ? 100000 : max) ? v : null);
357
357
  const text = (v, n) => clip(oneLine(v), n);
358
+ const HITS = ['met', 'partly', 'missed'];
359
+ const HEALTH = ['ok', 'warn', 'bad'];
360
+ const strs = (v, max, n) => (Array.isArray(v) ? v.map((x) => text(x, n)).filter(Boolean).slice(0, max) : []);
361
+ const iniOrNull = (v) => { const ini = v == null || v === '' ? null : String(v).trim(); return ini == null || INI_RE.test(ini) ? { ini } : null; };
362
+ /** The brief of an active or future sprint → { brief } | { error }. A plan is what the PM can do to move the ticket to done. */
363
+ function briefOf(b, name) {
364
+ const health = String(b.health || '').toLowerCase();
365
+ if (!HEALTH.includes(health)) return { error: `${name}: brief.health is ok, warn or bad` };
366
+ const verdict = text(b.verdict, 600);
367
+ if (!verdict) return { error: `${name}: brief.verdict is empty — say where the sprint stands` };
368
+ const issues = [];
369
+ for (const i of Array.isArray(b.issues) ? b.issues.slice(0, 8) : []) {
370
+ const title = i && text(i.title, 120);
371
+ if (!title) return { error: `${name}: an issue has no title` };
372
+ const sev = String(i.sev || '').toLowerCase();
373
+ if (!['bad', 'warn'].includes(sev)) return { error: `${name}: issue "${title}" needs sev bad (blocked) or warn (watch)` };
374
+ const plan = strs(i.plan, 5, 240);
375
+ if (!plan.length) return { error: `${name}: issue "${title}" needs a plan — the steps that move it to done` };
376
+ const io = iniOrNull(i.initiative);
377
+ if (!io) return { error: `${name}: issue "${title}" has a bad initiative id` };
378
+ issues.push({ sev, title, keys: text(i.keys, 80), initiative: io.ini, why: text(i.why, 300), by: text(i.by, 60), plan, doneWhen: text(i.doneWhen, 200) });
379
+ }
380
+ const done = [];
381
+ for (const d of Array.isArray(b.done) ? b.done.slice(0, 8) : []) {
382
+ const title = d && text(d.title, 120);
383
+ if (!title) return { error: `${name}: a done entry has no title` };
384
+ const io = iniOrNull(d.initiative);
385
+ if (!io) return { error: `${name}: done "${title}" has a bad initiative id` };
386
+ done.push({ title, keys: text(d.keys, 80), initiative: io.ini, line: text(d.line, 200), waiting: d.waiting === true });
387
+ }
388
+ return { brief: { health, verdict, issues, done, asks: strs(b.asks, 4, 240) } };
389
+ }
358
390
  /** One sprint's note from the agent → { note } | { error }. Nothing is guessed: a missing required field refuses the note. */
359
391
  function noteOne(x) {
360
392
  if (!x || typeof x !== 'object') return { error: 'a sprint note is not an object' };
@@ -381,6 +413,19 @@ function noteOne(x) {
381
413
  themes.push({ title, line: text(t.line, 200), tickets, done: Math.min(count(t.done) || 0, tickets), blocked: Math.min(count(t.blocked) || 0, tickets), people: text(t.people, 80), initiative: ini });
382
414
  }
383
415
  const note = { name, state, goal, summary, themes };
416
+ // The sprint brief (sprint-goal-summary, mockup-c3-sprint-brief): where the sprint stands, the issues to resolve with a proposed
417
+ // plan each, what is done and the asks for the PM. Written by the agent from the tickets; optional, so a note without one still loads.
418
+ if (state !== 'closed' && x.brief && typeof x.brief === 'object') {
419
+ const r = briefOf(x.brief, name);
420
+ if (r.error) return r;
421
+ note.brief = r.brief;
422
+ }
423
+ if (state === 'closed') {
424
+ const hit = String(x.hit || '').toLowerCase();
425
+ if (hit) { if (!HITS.includes(hit)) return { error: `${name}: hit is met, partly or missed` }; note.hit = hit; }
426
+ const gotInWay = text(x.got, 300);
427
+ if (gotInWay) note.got = gotInWay;
428
+ }
384
429
  const id = isStr(x.id) || Number.isInteger(x.id) ? String(x.id) : '';
385
430
  if (/^\d{1,12}$/.test(id)) note.id = id;
386
431
  if (state === 'closed' && x.closed && typeof x.closed === 'object') {
@@ -460,4 +505,4 @@ function which(root, key) {
460
505
  }
461
506
 
462
507
  module.exports = { queryFor, linkedInitiatives, normalize, sync, readSnapshot, which, sprintLabel, snapshotPath, snapshotRel, sprintQuery, syncSprints, readSprints, SPRINTS_FILE,
463
- sprintsToSummarise, saveSprintNotes, readSprintNotes, sameSprint, NOTES_FILE };
508
+ sprintsToSummarise, saveSprintNotes, readSprintNotes, sameSprint, noteOne, NOTES_FILE };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.212.0",
3
+ "version": "0.214.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.212.0",
9
+ "version": "0.214.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.212.0",
3
+ "version": "0.214.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",
@@ -391,11 +391,20 @@ export function handle(S, method, path, p, body) {
391
391
  const themes = [{ title: own[0] ? own[0].title : 'Sprint work', line: 'Most items are in progress or in review.', tickets: issues.filter((i) => i.initiative === ini0).length || 1, done: issues.filter((i) => i.initiative === ini0 && i.category === 'done').length, blocked: 1, people: 'Ana, Budi', initiative: ini0 },
392
392
  { title: 'Fixes and upkeep', line: 'Small fixes outside any initiative.', tickets: 2, done: 1, blocked: 0, people: 'Chen', initiative: null }];
393
393
  const act = { name: key + ' Sprint ' + num, state: 'active', issues, counts, goal: { from: 'tickets', text: 'Get the first initiative ready for internal users and clear the blocked work.', jira: 'Q4 delivery' },
394
- summary: 'Most of the sprint is on the first initiative. One item is blocked. Two small fixes sit outside any initiative.', themes, writtenAt: TODAY + 'T06:30:00Z', summarisedTickets: issues.length, closed: null, frozen: false };
394
+ summary: 'Most of the sprint is on the first initiative. One item is blocked. Two small fixes sit outside any initiative.', themes, writtenAt: TODAY + 'T06:30:00Z', summarisedTickets: issues.length, closed: null, frozen: false,
395
+ brief: { health: 'warn', verdict: 'At risk. Halfway through, 21 of 48 points are done. The team finishes about 27 points a sprint, so about 12 points will carry over unless scope is cut now. The blocked QA plan is the exposed part.',
396
+ issues: [{ sev: 'bad', title: 'QA test plan for the beta is blocked', keys: key + '-1373', initiative: ini0, why: 'Flagged in Jira for 4 days: no QA tester is free this sprint. The beta cannot start without it.', by: 'Decide today',
397
+ plan: ['Ask the QA lead whether another team can lend a tester for 3 days this week.', 'If yes, the tester writes the plan by Thursday and the beta keeps its date.', 'If no, move the beta by two weeks and tell the pilot customers this week.'], doneWhen: 'The ticket leaves Blocked with a named tester and a date.' },
398
+ { sev: 'warn', title: 'Code expiry has sat in review for 3 days', keys: key + '-1371', initiative: ini0, why: 'Review is the slowest step this sprint.', by: 'Raise at stand-up', plan: ['Ask at stand-up for a second reviewer this week.'], doneWhen: 'The ticket is merged.' }],
399
+ done: [{ title: 'Rate limiting approach chosen', keys: key + '-1370', initiative: ini0, line: 'Unblocks the build next sprint.', waiting: false }, { title: 'Footer shows the right year', keys: key + '-1379', initiative: null, line: 'Waiting for the next release to reach Prod.', waiting: true }],
400
+ asks: ['Decide how the beta gets QA: borrow a tester, or move the beta date.', 'Cut about 12 points now, starting with the lowest-value tickets.'] },
401
+ points: { committed: 48, done: 21, unestimated: 2 }, flow: [{ status: 'To Do', category: 'todo', n: counts.todo }, { status: 'In Progress', category: 'doing', n: counts.doing }, { status: 'Done', category: 'done', n: counts.done }].filter((f) => f.n),
402
+ pace: { day: 7, days: 14, daysLeft: 8, avg: 27, hitRate: 0.77, forecast: 36 } };
395
403
  const fut = { ...extra(1, 'future', key + ' Sprint ' + (num + 1), 'todo', 3), goal: { from: 'tickets', text: 'Finish the first initiative and start the beta.', jira: null }, summary: 'Planned so far: three follow-up items.', themes: [], writtenAt: TODAY + 'T06:30:00Z', closed: null, frozen: false };
396
404
  const old = { ...extra(0, 'closed', key + ' Sprint ' + (num - 1), 'done', 5), goal: { from: 'tickets', text: 'Start the first initiative.', jira: null }, summary: 'Goal met. Five items shipped; two moved to the next sprint.', themes: [{ title: 'Sprint work', line: '', tickets: 5, done: 5, blocked: 0, people: '', initiative: ini0 }],
397
- writtenAt: '2026-09-29T06:30:00Z', closed: { done: 5, total: 7, carried: 2, carriedTo: key + ' Sprint ' + num, points: '21 of 26' }, frozen: true };
398
- return okb({ source: 'jira', readAt: TODAY + 'T08:10:00', sprints: [act, fut, old] });
405
+ writtenAt: '2026-09-29T06:30:00Z', closed: { done: 5, total: 7, carried: 2, carriedTo: key + ' Sprint ' + num, points: '21 of 26' }, frozen: true, hit: 'met', got: 'The code entry screen waited 5 days on design.' };
406
+ const velocity = [{ name: key + ' Sprint ' + (num - 4), committed: 33, done: 24 }, { name: key + ' Sprint ' + (num - 3), committed: 36, done: 28 }, { name: key + ' Sprint ' + (num - 2), committed: 34, done: 26 }, { name: key + ' Sprint ' + (num - 1), committed: 38, done: 30 }];
407
+ return okb({ source: 'jira', readAt: TODAY + 'T08:10:00', sprints: [act, fut, old], velocity });
399
408
  }
400
409
  return fail(404, 'No route ' + path);
401
410
  }
@@ -1,43 +1,116 @@
1
1
  /* pm/c3-ib-sprints.js — the Sprints tab of a board with Build and deliver on (mockup v5, 34-initiatives.js tabSprints; the sprint goal
2
- * and team summary from mockup-c3-sprint-goal v3, initiative sprint-goal-summary).
2
+ * and team summary from mockup-c3-sprint-goal v3; the sprint brief from mockup-c3-sprint-brief v3, initiative sprint-goal-summary).
3
3
  * registerBoardTab('sprints', (host, ctx) => ...)
4
- * Reads GET /api/boards/sprints { source: jira|none, readAt, sprints: [{ name, state: active|future|closed, start, end, issues: [{ key, summary,
5
- * status, category: todo|doing|done, initiative }], counts, goal: { from: 'tickets', text, jira } | null, jiraGoal?, summary,
6
- * themes: [{ title, line, tickets, done, blocked, people, initiative }], writtenAt, closed: { done, total, carried, carriedTo, points } | null,
7
- * frozen }] }. READ-ONLY: the sprints mirror Jira, so there is no create, edit or move control.
8
- * Left: the active sprint's goal and summary (the goal, always written from the tickets, with Jira's goal as a reference; what the team is working on,
9
- * one row per theme), its items (header chip: Jira, read-only and the read time; c3-board-declutter: no banner), then the next sprint
10
- * with its goal and summary, and each closed sprint with its goal, summary, done and carried counts and the read-once note. Right: the
11
- * active sprint's card, its burndown from GET /api/stage-tools/burndown (when that route is not on this server yet, the card says so
12
- * quietly), and how sprints are read. source 'none' says there is no tracker. */
4
+ * Reads GET /api/boards/sprints { source: jira|none, readAt, velocity: [{ name, committed, done }], sprints: [{ name, state: active|future|closed,
5
+ * start, end, issues: [{ key, summary, status, category: todo|doing|done, initiative, flagged, points }], counts, goal: { from: 'tickets', text,
6
+ * jira } | null, jiraGoal?, summary, themes: [{ title, line, tickets, done, blocked, people, initiative }], brief: { health, verdict,
7
+ * issues: [{ sev, title, keys, initiative, why, by, plan, doneWhen }], done: [{ title, keys, initiative, line, waiting }], asks } | null,
8
+ * points: { committed, done, unestimated } | null, flow: [{ status, category, n }], pace: { day, days, daysLeft, avg, hitRate, forecast }
9
+ * (active only), writtenAt, closed: { done, total, carried, carriedTo, points } | null, hit, got, frozen }] }.
10
+ * READ-ONLY: the sprints mirror Jira, so there is no create, edit or move control. "Add to my Work" files a note in the PM's Work inbox
11
+ * (POST /api/capture); nothing is ever written to Jira.
12
+ * Left: the active sprint's brief (the goal, always written from the tickets; where the sprint stands; the issues to resolve, each with
13
+ * the agent's proposed plan; what is done; where the tickets are; what the team is working on; the asks for the PM), its tickets
14
+ * behind "Show all", then the next sprint's goal and summary. Right: the active sprint's card with points and the forecast, the
15
+ * velocity of the last four closed sprints, the burndown from GET /api/stage-tools/burndown, the past sprints (each opens in a
16
+ * sheet; closed sprints are kept out of the page, Wawan 2026-10-14) and how sprints are read. source 'none' says there is no tracker. */
13
17
  import { el } from './dom.js';
14
18
  import { registerBoardTab } from './c3-initiatives.js';
15
- import { icon, lk, chip, tag, st, note, lbl, sect, card, list, empty, nrow, cols, kv, jiraLabel, day, put, plural } from './c3-ini-ui.js';
19
+ import { icon, lk, chip, tag, st, lbl, sect, card, list, empty, nrow, cols, kv, jiraLabel, day, put, plural, pbtn, geo, healthSt, note } from './c3-ini-ui.js';
16
20
  import { loadItems } from './c3-ini-work.js';
17
21
  import { recordCards } from './c3-ini-records.js';
18
22
 
19
23
  const CAT = { todo: 'To do', doing: 'In progress', done: 'Done' };
20
24
  const readTime = (s) => { const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(String(s || '')); return m ? day(m[1]) + ', ' + m[2] : ''; };
21
25
  const timeOf = (s) => { const m = /T(\d{2}:\d{2})/.exec(String(s || '')); return m ? m[1] : ''; };
22
- /* Quiet pages (c3-quiet.css) hide .ini-note, .ini-lhm .dim and a row's third chip. The goal's reason, the theme chips and the
23
- * read-once note are the content of this tab (mockup v3), so they use their own classes. */
26
+ const num = (v) => (Number.isInteger(v) ? String(v) : String(Math.round(v * 10) / 10));
27
+ /* Quiet pages (c3-quiet.css) hide .ini-note, .ini-lhm .dim and a row's third chip. The goal's reason, the theme chips, the plans and
28
+ * the read-once note are the content of this tab, so they use their own .ini-sp-* classes. */
29
+ const spNote = (text, attrs) => el('div', { class: 'dim ini-sp-note', ...(attrs || {}) }, text);
24
30
  /* The goal is always written from the sprint's tickets: Jira's sprint goal is often empty, too broad or incomplete (the PM,
25
31
  * 2026-10-14). Jira's own text shows underneath as a reference. */
26
- const jiraRef = (j) => (j ? el('div', { class: 'dim ini-sp-note', 'data-inb': 'sprint-goal-jira' }, 'In Jira: “' + j + '”') : null);
32
+ const jiraRef = (j) => (j ? spNote('In Jira: “' + j + '”', { 'data-inb': 'sprint-goal-jira' }) : null);
27
33
  function goalBlock(g, jiraGoal) {
28
34
  if (!g) return el('div', { class: 'lh', 'data-inb': 'sprint-goal', 'data-inb-goal': 'missing' }, el('span', { class: 'ki' }, icon('target')),
29
- el('div', { class: 'ini-sp-goal' }, lbl('SPRINT GOAL'), el('div', { class: 'dim ini-sp-note' }, 'No goal yet. The Jira status check writes one from the tickets at its next check.'), jiraRef(jiraGoal)));
35
+ el('div', { class: 'ini-sp-goal' }, lbl('SPRINT GOAL'), spNote('No goal yet. The Jira status check writes one from the tickets at its next check.'), jiraRef(jiraGoal)));
30
36
  return el('div', { class: 'lh', 'data-inb': 'sprint-goal', 'data-inb-goal': 'tickets' }, el('span', { class: 'ki' }, icon('target')),
31
37
  el('div', { class: 'ini-sp-goal' }, lbl('SPRINT GOAL · from the tickets'), el('b', null, g.text), jiraRef(g.jira)));
32
38
  }
39
+ const iniChip = (id, ctx, attr) => (id ? lk({ onclick: () => ctx.openInitiative(id), [attr]: id }, icon('init', 'sm'), id) : el('span', { class: 'chip2 dim' }, 'No initiative'));
33
40
  const themeRow = (t, ctx) => nrow({ ic: t.blocked ? 'alert' : t.tickets && t.done === t.tickets ? 'check' : t.initiative ? 'bolt' : 'task', hot: !t.blocked && t.done < t.tickets && !!t.initiative,
34
41
  title: t.title, attrs: { 'data-inb-theme': t.initiative || 'none' },
35
- after: el('div', null, t.line ? el('div', { class: 'dim ini-sp-note' }, t.line) : null, el('div', { class: 'chips ini-sp-chips' }, ...[chip(plural(t.tickets, 'ticket')), t.done ? st('ok', 'check', t.done + ' done') : null, t.blocked ? st('warn', 'alert', t.blocked + ' blocked') : null,
36
- t.people ? chip(icon('user', 'sm'), t.people) : null,
37
- t.initiative ? lk({ onclick: () => ctx.openInitiative(t.initiative), 'data-inb-theme-ini': t.initiative }, icon('init', 'sm'), t.initiative) : el('span', { class: 'chip2 dim' }, 'No initiative')].filter(Boolean))) });
38
- const summaryBody = (sp, ctx) => [goalBlock(sp.goal, sp.jiraGoal), sp.summary ? el('p', { class: 'ini-sp-sum' }, sp.summary) : null,
39
- sp.themes && sp.themes.length ? [lbl('WHAT THE TEAM IS WORKING ON'), list(sp.themes.map((t) => themeRow(t, ctx)), { 'data-inb': 'sprint-themes' })] : null];
42
+ after: el('div', null, t.line ? spNote(t.line) : null, el('div', { class: 'chips ini-sp-chips' }, ...[chip(plural(t.tickets, 'ticket')), t.done ? st('ok', 'check', t.done + ' done') : null, t.blocked ? st('warn', 'alert', t.blocked + ' blocked') : null,
43
+ t.people ? chip(icon('user', 'sm'), t.people) : null, iniChip(t.initiative, ctx, 'data-inb-theme-ini')].filter(Boolean))) });
44
+ const themesBlock = (sp, ctx) => (sp.themes && sp.themes.length ? [lbl('WHAT THE TEAM IS WORKING ON'), list(sp.themes.map((t) => themeRow(t, ctx)), { 'data-inb': 'sprint-themes' })] : []);
40
45
  const countsOf = (sp) => { const c = sp.counts || {}; const f = (k) => (c[k] != null ? Number(c[k]) : (sp.issues || []).filter((i) => i.category === k).length); return { todo: f('todo'), doing: f('doing'), done: f('done') }; };
46
+ const dates = (s) => (s.start && s.end ? day(s.start) + ' to ' + day(s.end) : '');
47
+
48
+ /* ── the sprint brief: PMOS counts the numbers, the agent writes the words ── */
49
+ function verdictBlock(sp) {
50
+ const p = sp.points; const pace = sp.pace || {};
51
+ const blocked = (sp.issues || []).filter((i) => i.flagged).length;
52
+ const facts = [
53
+ pace.day && pace.days ? chip(icon('cal', 'sm'), 'Day ' + pace.day + ' of ' + pace.days + ' · ' + Math.round(pace.day / pace.days * 100) + '% of the time') : null,
54
+ p ? chip(num(p.done) + ' of ' + num(p.committed) + ' points · ' + (p.committed ? Math.round(p.done / p.committed * 100) : 0) + '%') : null,
55
+ p && p.unestimated ? chip(plural(p.unestimated, 'ticket') + ' with no estimate') : null,
56
+ blocked ? st('bad', 'alert', blocked + ' blocked') : null,
57
+ ].filter(Boolean);
58
+ return el('div', { class: 'lh ini-sp-verdict', 'data-inb': 'sprint-verdict', 'data-inb-health': sp.brief.health },
59
+ el('span', { class: 'ki' + (sp.brief.health === 'ok' ? '' : ' hot') }, icon(sp.brief.health === 'ok' ? 'check' : 'alert')),
60
+ el('div', { class: 'ini-sp-goal' }, lbl('WHERE THE SPRINT STANDS'), el('div', null, sp.brief.verdict), facts.length ? el('div', { class: 'chips ini-sp-chips' }, ...facts) : null));
61
+ }
62
+ function addToWork(ctx, x) {
63
+ const text = 'Sprint issue: ' + x.title + (x.keys ? ' (' + x.keys + ')' : '') + (x.plan.length ? '. Next: ' + x.plan[0] : '') + (x.doneWhen ? ' Done when: ' + x.doneWhen : '');
64
+ if (ctx.demo) { ctx.toast('Added to your Work inbox: ' + x.title); return; }
65
+ ctx.api.post('/api/capture', { ws: ctx.ws, text }).then((r) => ctx.toast(r && r.ok !== false ? 'Added to your Work inbox. PMOS sorts it.' : { text: 'That was not added' + (r && r.error ? ': ' + r.error : '.'), tone: 'error' }));
66
+ }
67
+ const issueRow = (x, ctx) => nrow({ ic: x.sev === 'bad' ? 'alert' : 'clock', hot: x.sev === 'bad', title: x.title, attrs: { 'data-inb-sp-issue': x.sev },
68
+ after: el('div', null, x.why ? spNote(x.why) : null,
69
+ el('div', { class: 'chips ini-sp-chips' }, ...[x.sev === 'bad' ? st('bad', 'alert', 'Blocked') : st('warn', 'alert', 'Watch'), x.keys ? el('span', { class: 'chip2 mono' }, x.keys) : null,
70
+ iniChip(x.initiative, ctx, 'data-inb-sp-issue-ini'), x.by ? chip(icon('clock', 'sm'), x.by) : null].filter(Boolean)),
71
+ el('div', { class: 'ini-sp-plan', 'data-inb': 'sprint-plan' }, lbl('PROPOSED PLAN'), el('ol', null, ...x.plan.map((t) => el('li', null, t))),
72
+ x.doneWhen ? spNote('Done when: ' + x.doneWhen) : null,
73
+ el('div', { class: 'chips ini-sp-chips' }, pbtn('outline', 'Add to my Work', 'plus', { onclick: () => addToWork(ctx, x), 'data-inb-sp-addwork': x.title })))) });
74
+ const doneRow = (d, ctx) => nrow({ ic: 'check', title: d.title, attrs: { 'data-inb-sp-done': d.waiting ? 'waiting' : 'done' },
75
+ after: el('div', null, d.line ? spNote(d.line) : null, el('div', { class: 'chips ini-sp-chips' }, ...[d.waiting ? st('warn', 'clock', 'Not in Prod yet') : st('ok', 'check', 'Done'),
76
+ d.keys ? el('span', { class: 'chip2 mono' }, d.keys) : null, iniChip(d.initiative, ctx, 'data-inb-sp-done-ini')].filter(Boolean))) });
77
+ const FLOW_TONE = { todo: 'todo', doing: 'doing', done: 'done' };
78
+ function flowBlock(sp) {
79
+ const f = sp.flow || [];
80
+ if (!f.length) return [];
81
+ const label = f.map((x) => x.n + ' ' + x.status).join(', ');
82
+ return [lbl('WHERE THE TICKETS ARE'),
83
+ el('div', { class: 'ini-sp-flow', role: 'img', 'aria-label': label, 'data-inb': 'sprint-flow' }, ...f.map((x) => geo(el('u', { class: 'ini-sp-f-' + FLOW_TONE[x.category], title: x.n + ' ' + x.status }), { '--sp-n': x.n }))),
84
+ el('div', { class: 'chips ini-sp-chips' }, ...f.map((x) => chip(el('i', { class: 'ini-sp-dot ini-sp-f-' + FLOW_TONE[x.category], 'aria-hidden': 'true' }), x.n + ' ' + x.status.toLowerCase())))];
85
+ }
86
+ function briefBody(sp, ctx) {
87
+ const b = sp.brief;
88
+ if (!b) return [goalBlock(sp.goal, sp.jiraGoal), sp.summary ? el('p', { class: 'ini-sp-sum' }, sp.summary) : null, ...flowBlock(sp), ...themesBlock(sp, ctx)];
89
+ return [goalBlock(sp.goal, sp.jiraGoal), verdictBlock(sp),
90
+ b.issues.length ? [lbl('ISSUES TO RESOLVE · and how to close them'), list(b.issues.map((x) => issueRow(x, ctx)), { 'data-inb': 'sprint-issues' })] : spNote('No issue to resolve.', { 'data-inb': 'sprint-no-issues-to-resolve' }),
91
+ b.done.length ? [lbl('COMPLETED SO FAR'), list(b.done.map((d) => doneRow(d, ctx)), { 'data-inb': 'sprint-done' })] : null,
92
+ ...flowBlock(sp), ...themesBlock(sp, ctx),
93
+ b.asks.length ? [lbl('FOR YOU'), list(b.asks.map((a, k) => nrow({ ic: k ? 'next' : 'target', hot: !k, title: a })), { 'data-inb': 'sprint-asks' })] : null];
94
+ }
95
+
96
+ /* ── velocity: the last four closed sprints, committed against done, and this sprint so far ── */
97
+ const SVGNS = 'http://www.w3.org/2000/svg';
98
+ const svg = (tag, attrs, text) => { const n = document.createElementNS(SVGNS, tag); for (const [k, v] of Object.entries(attrs || {})) n.setAttribute(k, String(v)); if (text != null) n.textContent = text; return n; };
99
+ function velocitySvg(vel, active) {
100
+ const rows = [...vel.map((v) => ({ n: v.name.replace(/^.*?(\d+)\s*$/, 'S$1'), c: v.committed, d: v.done })), ...(active && active.points ? [{ n: 'now', c: active.points.committed, d: active.points.done, now: true }] : [])];
101
+ const W = 360; const H = 150; const max = Math.max(1, ...rows.map((r) => r.c)) * 1.1;
102
+ const avg = vel.reduce((a, v) => a + v.done, 0) / vel.length; const bw = (W - 50) / rows.length; const y = (v) => 10 + (1 - v / max) * (H - 32);
103
+ const s = svg('svg', { class: 'ini-vel', viewBox: '0 0 ' + W + ' ' + H, role: 'img', 'aria-label': 'Velocity: ' + num(avg) + ' points done a sprint on average' });
104
+ s.append(svg('line', { class: 'ax', x1: 34, y1: y(0), x2: W - 10, y2: y(0) }));
105
+ rows.forEach((r, k) => {
106
+ const x = 36 + k * bw + bw * 0.18; const w = bw * 0.64;
107
+ s.append(svg('rect', { class: 'com' + (r.now ? ' now' : ''), x, y: y(r.c), width: w, height: Math.max(0, y(0) - y(r.c)), rx: 2 }),
108
+ svg('rect', { class: 'done' + (r.now ? ' now' : ''), x, y: y(r.d), width: w, height: Math.max(0, y(0) - y(r.d)), rx: 2 }),
109
+ svg('text', { class: 'v', x: x + w / 2, y: y(r.d) - 4, 'text-anchor': 'middle' }, num(r.d)), svg('text', { x: x + w / 2, y: H - 6, 'text-anchor': 'middle' }, r.n));
110
+ });
111
+ s.append(svg('line', { class: 'avg', x1: 34, y1: y(avg), x2: W - 10, y2: y(avg) }), svg('text', { x: 30, y: y(avg) + 3, 'text-anchor': 'end' }, num(avg)));
112
+ return s;
113
+ }
41
114
 
42
115
  registerBoardTab('sprints', async (host, ctx) => {
43
116
  let dead = false;
@@ -48,49 +121,79 @@ registerBoardTab('sprints', async (host, ctx) => {
48
121
  if (!r || !r.ok) { put(host, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The sprints could not be read: ' + ((r && r.error) || 'no answer') + '.')); return () => { dead = true; }; }
49
122
  if (r.source === 'none') { put(host, empty('Waiting for the first sync from Jira ' + jiraLabel(b) + '. Sprints will be mirrored here, read-only.', { 'data-inb': 'sprints-none' })); return () => { dead = true; }; }
50
123
  const sprints = r.sprints || [];
124
+ const vel = Array.isArray(r.velocity) ? r.velocity : [];
51
125
  const active = sprints.find((s) => s.state === 'active');
52
- const others = sprints.filter((s) => s !== active);
53
- const issueRow = (i) => nrow({ ic: i.category === 'done' ? 'check' : i.category === 'doing' ? 'bolt' : 'todo', hot: i.category === 'doing', title: i.summary || i.key,
126
+ const future = sprints.filter((s) => s.state !== 'closed' && s !== active);
127
+ const past = sprints.filter((s) => s.state === 'closed');
128
+ const ticketRow = (i) => nrow({ ic: i.category === 'done' ? 'check' : i.category === 'doing' ? 'bolt' : 'todo', hot: i.category === 'doing', title: i.summary || i.key,
54
129
  chips: [el('span', { class: 'chip2 mono' }, i.key), tag(icon('jira', 'sm'), 'Jira'), i.category === 'done' ? st('ok', 'check', i.status || 'Done') : chip(i.status || CAT[i.category] || ''),
55
130
  i.initiative ? lk({ onclick: () => ctx.openInitiative(i.initiative), 'data-inb-issue-ini': i.initiative }, icon('init', 'sm'), i.initiative) : el('span', { class: 'dim' }, 'No initiative')],
56
131
  right: [el('span', { class: 'chip2', title: 'Status lives in Jira' }, icon('lock', 'sm'))], attrs: { 'data-inb-issue': i.key } });
57
132
  const t = readTime(r.readAt);
58
- const dates = (s) => (s.start && s.end ? day(s.start) + ' to ' + day(s.end) : '');
59
- const itemsRow = (s) => { const c = countsOf(s); return nrow({ ic: 'clock', title: c.done + ' done, ' + c.doing + ' in progress, ' + c.todo + ' to do', xp: (s.issues || []).length ? [list(s.issues.map(issueRow))] : [note('No items.')] }); };
133
+ const itemsRow = (s) => { const c = countsOf(s); return nrow({ ic: 'clock', title: c.done + ' done, ' + c.doing + ' in progress, ' + c.todo + ' to do', xp: (s.issues || []).length ? [list(s.issues.map(ticketRow))] : [note('No items.')] }); };
134
+ /* the tickets stay behind a press: the brief says what matters, the list is there when it is needed (Wawan, 2026-10-14) */
135
+ function ticketsSect(s) {
136
+ const n = (s.issues || []).length; const c = countsOf(s);
137
+ const hold = el('div', { 'data-inb': 'sprint-ticket-list', hidden: true }, n ? list(s.issues.map(ticketRow)) : empty('No items in this sprint yet.', { 'data-inb': 'sprint-no-issues' }));
138
+ const show = pbtn('outline', 'Show all ' + plural(n, 'ticket'), 'ul', { 'data-inb': 'sprint-show-tickets', 'aria-expanded': 'false', onclick: () => {
139
+ hold.hidden = !hold.hidden; show.setAttribute('aria-expanded', String(!hold.hidden));
140
+ const w = document.createTreeWalker(show, NodeFilter.SHOW_TEXT); let last = null; while (w.nextNode()) last = w.currentNode; if (last) last.textContent = (hold.hidden ? 'Show all ' : 'Hide ') + plural(n, 'ticket');
141
+ } });
142
+ return sect({ ic: 'check', title: s.name + ' tickets', count: n, right: el('span', { class: 'chip2', 'data-inb': 'sprints-source', title: 'Mirrored from Jira ' + jiraLabel(b) + '. Every issue in the sprint, whoever it is assigned to. Change sprints in Jira.' }, icon('lock', 'sm'), 'Jira, read-only' + (t ? ' · read ' + t : '')), attrs: { 'data-inb-sprint': s.name, 'data-inb-state': 'active' } },
143
+ el('div', { class: 'chips ini-sp-chips' }, show, spNote(c.done + ' done, ' + c.doing + ' in progress, ' + c.todo + ' to do.')), hold);
144
+ }
60
145
  const closedSect = (s) => {
61
146
  const c = countsOf(s); const x = s.closed || {}; const n = x.total || (s.issues || []).length; const done = x.total ? x.done : c.done;
62
147
  return sect({ ic: 'check', title: s.name, count: n, right: tag('closed'), attrs: { 'data-inb-sprint': s.name, 'data-inb-state': 'closed' } },
63
- ...summaryBody({ ...s, themes: [] }, ctx),
148
+ goalBlock(s.goal, s.jiraGoal),
149
+ s.summary || s.hit ? el('p', { class: 'ini-sp-sum' }, s.hit === 'met' ? st('ok', 'check', 'Goal met') : s.hit === 'partly' ? st('warn', 'alert', 'Goal partly met') : s.hit === 'missed' ? st('bad', 'alert', 'Goal missed') : null, s.hit && s.summary ? ' ' : null, s.summary || null) : null,
64
150
  el('div', { class: 'chips ini-sp-facts' }, dates(s) ? chip(icon('cal', 'sm'), dates(s)) : null, st('ok', 'check', done + ' of ' + n + ' done'),
65
151
  x.points ? chip(x.points + ' points') : null, x.carried ? chip(icon('next', 'sm'), x.carried + ' carried' + (x.carriedTo ? ' to ' + x.carriedTo : '')) : null),
66
- el('div', { class: 'dim ini-sp-note', 'data-inb': 'sprint-read-once' }, icon('lock', 'sm'), s.frozen ? ' Closed. Read and summarised once on ' + day(String(s.writtenAt || '').slice(0, 10)) + '. PMOS does not read a closed sprint again.'
67
- : ' Closed. The Jira status check summarises it once at its next check, then does not read it again.'),
152
+ s.got ? el('div', { class: 'lh', 'data-inb': 'sprint-got' }, el('span', { class: 'ki' }, icon('alert')), el('div', { class: 'ini-sp-goal' }, lbl('WHAT GOT IN THE WAY'), el('div', null, s.got))) : null,
153
+ spNote([icon('lock', 'sm'), s.frozen ? ' Closed. Read and summarised once on ' + day(String(s.writtenAt || '').slice(0, 10)) + '. PMOS does not read a closed sprint again.'
154
+ : ' Closed. The Jira status check summarises it once at its next check, then does not read it again.'], { 'data-inb': 'sprint-read-once' }),
68
155
  itemsRow(s));
69
156
  };
157
+ const openPast = () => ctx.openSheet({ title: 'Past sprints · ' + b.name, icon: 'check', wide: true, body: el('div', { 'data-inb': 'sprints-past-sheet' }, ...past.map(closedSect)) });
70
158
  const n = active ? (active.issues || []).length : 0;
71
159
  const left = [
72
- active ? sect({ ic: 'clock', title: active.name + ' · goal and summary', right: st('ok', null, 'active'), attrs: { 'data-inb': 'sprint-summary' } },
73
- ...summaryBody(active, ctx),
74
- el('div', { class: 'dim ini-sp-note', 'data-inb': 'sprint-written' }, active.summary ? 'Goal and summary written by the Jira status check' + (timeOf(active.writtenAt) ? ' at ' + timeOf(active.writtenAt) : '') + ' from all ' + plural(active.summarisedTickets != null ? active.summarisedTickets : n, 'ticket') + ' in the sprint. Read and summarised again at each check while the sprint is active.'
75
- : 'No summary yet. The Jira status check writes one from the sprint\'s tickets at its next check.')) : null,
76
- active ? sect({ ic: 'check', title: active.name + ' items', count: n, right: el('span', { class: 'chip2', 'data-inb': 'sprints-source', title: 'Mirrored from Jira ' + jiraLabel(b) + '. Every issue in the sprint, whoever it is assigned to. Change sprints in Jira.' }, icon('lock', 'sm'), 'Jira, read-only' + (t ? ' · read ' + t : '')), attrs: { 'data-inb-sprint': active.name, 'data-inb-state': 'active' } },
77
- n ? list(active.issues.map(issueRow)) : empty('No items in this sprint yet.', { 'data-inb': 'sprint-no-issues' }))
78
- : empty(sprints.length ? 'No sprint is active.' : 'Jira has no sprints for this board.', { 'data-inb': sprints.length ? 'sprints-no-active' : 'sprints-empty' }),
79
- ...others.filter((s) => s.state !== 'closed').map((s) => sect({ ic: 'cal', title: s.name, count: (s.issues || []).length, right: tag(s.state || 'sprint'), attrs: { 'data-inb-sprint': s.name, 'data-inb-state': s.state } },
160
+ active ? sect({ ic: 'clock', title: active.name + ' · sprint brief', right: active.brief ? healthSt(active.brief.health) : st('ok', null, 'active'), attrs: { 'data-inb': 'sprint-summary' } },
161
+ ...briefBody(active, ctx),
162
+ spNote(active.summary ? 'Written by the Jira status check' + (timeOf(active.writtenAt) ? ' at ' + timeOf(active.writtenAt) : '') + ' from all ' + plural(active.summarisedTickets != null ? active.summarisedTickets : n, 'ticket') + ' in the sprint' + (vel.length ? ' and the last ' + plural(vel.length, 'closed sprint') : '') + '. PMOS counted the numbers; the agent wrote the words. Read again at each check while the sprint is active.'
163
+ : 'No brief yet. The Jira status check writes one from the sprint\'s tickets at its next check.', { 'data-inb': 'sprint-written' })) : null,
164
+ active ? ticketsSect(active) : empty(sprints.length ? 'No sprint is active.' : 'Jira has no sprints for this board.', { 'data-inb': sprints.length ? 'sprints-no-active' : 'sprints-empty' }),
165
+ ...future.map((s) => sect({ ic: 'cal', title: s.name, count: (s.issues || []).length, right: tag(s.state || 'sprint'), attrs: { 'data-inb-sprint': s.name, 'data-inb-state': s.state } },
80
166
  goalBlock(s.goal, s.jiraGoal), s.summary ? el('p', { class: 'ini-sp-sum' }, s.summary) : null, itemsRow(s))),
81
- ...others.filter((s) => s.state === 'closed').map(closedSect),
82
167
  ];
83
168
  const right = [];
84
169
  if (active) {
85
- const c = countsOf(active);
170
+ const c = countsOf(active); const p = active.points; const pace = active.pace || {};
86
171
  right.push(card({ ic: 'clock', title: active.name, attrs: { 'data-inb': 'sprint-counts' } }, kv([dates(active) ? ['Dates', dates(active)] : null,
87
- ['Items', n + ': ' + c.done + ' done, ' + c.doing + ' in progress, ' + c.todo + ' to do']])));
172
+ ['Items', n + ': ' + c.done + ' done, ' + c.doing + ' in progress, ' + c.todo + ' to do'],
173
+ p ? ['Committed', num(p.committed) + ' points'] : null, p ? ['Done', num(p.done) + ' points'] : null,
174
+ p && pace.daysLeft != null ? ['Left · ' + plural(pace.daysLeft, 'day'), num(Math.max(0, p.committed - p.done)) + ' points'] : null,
175
+ pace.forecast != null ? ['Forecast', 'about ' + num(pace.forecast) + ' of ' + num(p.committed) + ' points' + (active.end ? ' by ' + day(active.end) : '')] : null])));
176
+ }
177
+ if (vel.length) {
178
+ const avg = vel.reduce((a, v) => a + v.done, 0) / vel.length; const pace = (active && active.pace) || {};
179
+ const over = active && active.points && avg ? Math.round((active.points.committed / avg - 1) * 100) : null;
180
+ right.push(card({ ic: 'up', title: 'Velocity', sub: 'last ' + plural(vel.length, 'sprint'), attrs: { 'data-inb': 'sprint-velocity' } }, velocitySvg(vel, active),
181
+ spNote('Average ' + num(avg) + ' points done a sprint' + (pace.hitRate != null ? ', ' + Math.round(pace.hitRate * 100) + '% of what was committed' : '') + '.' + (over != null ? ' This sprint committed ' + num(active.points.committed) + (over > 0 ? ', ' + over + '% above that average.' : over < 0 ? ', ' + -over + '% below that average.' : ', the same as the average.') : ''))));
182
+ }
183
+ if (active) {
88
184
  const ids = items.ok ? items.initiatives.map((i) => i.id) : [];
89
185
  const pseudo = { ...ctx, row: { id: ids.join(','), title: '' } };
90
186
  right.push(...await recordCards(pseudo, ['burndown']));
91
187
  }
188
+ if (past.length) right.push(card({ ic: 'lock', title: 'Past sprints', attrs: { 'data-inb': 'sprints-past' } },
189
+ list(past.map((s) => nrow({ ic: 'check', title: s.name, onOpen: openPast, attrs: { 'data-inb-past': s.name },
190
+ after: el('div', null, spNote([dates(s), s.closed && s.closed.total ? s.closed.done + ' of ' + s.closed.total + ' done' : null, s.closed && s.closed.points ? s.closed.points + ' points' : null].filter(Boolean).join(' · ')),
191
+ s.hit ? el('div', { class: 'chips ini-sp-chips' }, s.hit === 'met' ? st('ok', 'check', 'Goal met') : s.hit === 'partly' ? st('warn', 'alert', 'Goal partly met') : st('bad', 'alert', 'Goal missed')) : null) }))),
192
+ el('div', { class: 'chips ini-sp-chips' }, pbtn('outline', 'Open ' + plural(past.length, 'past sprint'), 'next', { onclick: openPast, 'data-inb': 'sprints-past-open' })),
193
+ spNote('Closed sprints are read and summarised once, then kept here.')));
92
194
  if (sprints.length) right.push(card({ ic: 'redo', title: 'How sprints are read', attrs: { 'data-inb': 'sprints-how' } }, kv([
93
- ['Goal', 'Written from the sprint\'s tickets. Jira\'s goal is shown for reference'], ['Active sprint', 'Read and summarised at each Jira status check'],
195
+ ['Goal', 'Written from the sprint\'s tickets. Jira\'s goal is shown for reference'], ['Numbers', 'Counted by PMOS from the Jira read: points, statuses and flags'],
196
+ ['Words', 'The verdict, the issues and their plans, and the asks are written by the agent from the tickets'], ['Active sprint', 'Read and summarised at each Jira status check'],
94
197
  ['Next sprint', 'Read at each check, until it starts'], ['Closed sprints', 'Read and summarised once, then kept'], ['Jira', 'Read-only. PMOS changes nothing there']])));
95
198
  if (dead) return () => {};
96
199
  put(host, cols(left, right));
@@ -7,7 +7,7 @@
7
7
  * after moving its Jira epic links and PMOS tasks.
8
8
  *
9
9
  * rowMenu(anchor, L, r) the row's "…" menu: Move to a stage, Status, Tidy up
10
- * pageMenu(anchor, L, r) the initiative page's "…" menu: Status and Tidy up
10
+ * pageMenu(anchor, L, r, own) the initiative page's "…" menu: the page's own items, Status and Tidy up
11
11
  * lifeBanner(L, r) the page banner of an on-hold, cancelled or archived initiative, or null
12
12
  * lifeChips(r) · archivedChips(r) · dupChip(L, r) the chips a row shows
13
13
  * restoreBtn(L, r) Restore, or Undo merge
@@ -329,9 +329,9 @@ export function rowMenu(anchor, L, r, onMove) {
329
329
  ...(status.length ? [{ group: 'Status' }, ...status] : []),
330
330
  { group: 'Tidy up' }, ...tidyItems(L, r)] });
331
331
  }
332
- export function pageMenu(anchor, L, r) {
332
+ export function pageMenu(anchor, L, r, own) {
333
333
  const status = statusItems(L, r);
334
- openMenu(anchor, { label: 'Actions for ' + r.id, items: [...(status.length ? [{ group: 'Status' }, ...status] : []), { group: 'Tidy up' }, ...tidyItems(L, r)] });
334
+ openMenu(anchor, { label: 'Actions for ' + r.id, items: [...(own && own.length ? [{ group: 'This initiative' }, ...own] : []), ...(status.length ? [{ group: 'Status' }, ...status] : []), { group: 'Tidy up' }, ...tidyItems(L, r)] });
335
335
  }
336
336
 
337
337
  /* ── chips and the banner ── */
@@ -60,6 +60,24 @@
60
60
  .c3 .ini-sp-goal{min-width:0}
61
61
  .c3 .ini-sp-note{font-size:var(--fs-2);line-height:1.45;margin-top:var(--sp-1)}
62
62
  .c3 .ini-sp-chips{margin-top:var(--sp-1)}
63
+ .c3 .ini-sp-verdict{margin:var(--sp-3) 0;padding:var(--sp-3);border:1px solid var(--border);border-radius:8px} /* the sprint brief (sprint-goal-summary) */
64
+ .c3 .ini-sp-plan{margin-top:var(--sp-2);padding:var(--sp-2) var(--sp-3);border-left:2px solid var(--acc)}
65
+ .c3 .ini-sp-plan ol{margin:var(--sp-1) 0;padding-left:1.2em;display:grid;gap:var(--sp-1);line-height:1.45}
66
+ .c3 .ini-sp-flow{display:flex;gap:2px;height:10px;border-radius:6px;overflow:hidden;margin:var(--sp-2) 0}
67
+ .c3 .ini-sp-flow u{display:block;flex:var(--sp-n,1) 1 0;min-width:4px}
68
+ .c3 .ini-sp-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:4px}
69
+ .c3 .ini-sp-f-todo{background:var(--muted-foreground)}
70
+ .c3 .ini-sp-f-doing{background:var(--acc)}
71
+ .c3 .ini-sp-f-done{background:var(--foreground)}
72
+ .c3 .ini-vel{display:block;width:100%;max-width:420px;height:auto}
73
+ .c3 .ini-vel .ax{stroke:var(--border)}
74
+ .c3 .ini-vel .com{fill:none;stroke:var(--muted-foreground)}
75
+ .c3 .ini-vel .com.now{stroke-dasharray:3 3}
76
+ .c3 .ini-vel .done{fill:var(--acc)}
77
+ .c3 .ini-vel .done.now{fill:var(--warning)}
78
+ .c3 .ini-vel .avg{stroke:var(--foreground);stroke-dasharray:4 3}
79
+ .c3 .ini-vel text{fill:var(--muted-foreground);font:var(--fs-1) var(--mono)}
80
+ .c3 .ini-vel text.v{fill:var(--foreground)}
63
81
  .c3 .ini-num{width:84px;flex:none}
64
82
  .c3 .ini-up,.c3 .ini-down{display:inline-flex}
65
83
  .c3 .ini-up{transform:rotate(-90deg)}
@@ -738,20 +738,21 @@ export function mountInitiatives(pane, opts) {
738
738
  const ctxI = makeCtx(bb, { row, history, tools, toolsByCat, tab, tool: P.loc.tool || '' });
739
739
  const items = await loadItems(ctxI);
740
740
  if (token !== P.seq) return;
741
- const work = items.items.filter((w) => w.ini === row.id); const ep = work.filter((w) => w.kind === 'Epic').length;
741
+ const work = items.items.filter((w) => w.ini === row.id);
742
742
  const wf = wfOf(row, bb); const idx = wf.findIndex((s) => s.name === row.stage); const cat = row.cat || catOf(wf, row.stage);
743
743
  const warnings = Array.isArray(row.warnings) ? row.warnings : [];
744
- const stepper = el('div', { class: 'calbar' },
745
- el('div', { class: 'seg ini-seg', role: 'group', 'aria-label': 'Workflow position', 'data-ini': 'stepper' },
746
- ...wf.map((s, k) => btn({ 'aria-pressed': String(k === idx), 'aria-current': k === idx ? 'step' : 'false', 'data-ini-step': s.name, title: bb.self ? SELF_WHY : k === idx ? 'Current stage' : 'Move to ' + s.name, onclick: () => stepTo(s.name) },
747
- k < idx ? icon('check', 'sm') : sw(s.cat), s.name))),
748
- el('span', { class: 'chips2' }, mono(tplName(row, bb) + ' workflow · ' + cat + ' · you decide when it moves'),
749
- pill({ 'data-ini': 'edit-workflow', 'aria-haspopup': 'dialog', onclick: () => openWorkflow(bb, row) }, icon('settings', 'sm'), 'Edit workflow')));
744
+ // c3-initiative-declutter: three rows above the content. The breadcrumb carries the title, status and warnings;
745
+ // the stepper carries a gear for the workflow and one Tools menu; the tabs carry the counts.
750
746
  const kind = kindOf(row, bb);
751
747
  const toolList = [...recordToolsFor(kind, cat, bb.build).map((id) => ({ id, label: RECORD_META[id][0], rec: true })), ...(Array.isArray(tools) ? tools : []).map((t) => (typeof t === 'string' ? { id: t } : t)).filter((t) => t && t.id)];
752
- const moreBtn = pill({ 'data-ini': 'more-tools', 'aria-haspopup': 'menu', 'aria-expanded': 'false', onclick: (e) => moreTools(e.currentTarget, row, bb, cat, kind) }, icon('more', 'sm'), 'More tools');
753
- const strip = el('div', { class: 'chips2', 'data-ini': 'tools' }, mono('Tools for ' + row.stage),
754
- ...toolList.map((t) => pill({ 'data-ini-tool': t.id, 'aria-pressed': String(P.loc.tool === t.id), onclick: () => pickTool(t.id) }, icon(t.rec ? RECORD_META[t.id][1] : toolIcon(t.id), 'sm'), toolLabel(t))), moreBtn);
748
+ const toolsBtn = pill({ 'data-ini': 'tools', 'aria-haspopup': 'menu', 'aria-expanded': 'false', onclick: (e) => openTools(e.currentTarget, row, bb, cat, kind, toolList) }, icon('bolt', 'sm'), 'Tools');
749
+ const stepper = el('div', { class: 'calbar' },
750
+ el('span', { class: 'chips2' },
751
+ el('div', { class: 'seg ini-seg', role: 'group', 'aria-label': 'Workflow position', 'data-ini': 'stepper' },
752
+ ...wf.map((s, k) => btn({ 'aria-pressed': String(k === idx), 'aria-current': k === idx ? 'step' : 'false', 'data-ini-step': s.name, title: bb.self ? SELF_WHY : k === idx ? 'Current stage' : 'Move to ' + s.name, onclick: () => stepTo(s.name) },
753
+ k < idx ? icon('check', 'sm') : sw(s.cat), s.name))),
754
+ ib('Edit workflow', 'settings', { 'data-ini': 'edit-workflow', 'aria-haspopup': 'dialog', title: tplName(row, bb) + ' workflow, now in ' + cat + '. You decide when it moves. Edit workflow', onclick: () => openWorkflow(bb, row) })),
755
+ toolsBtn);
755
756
  const tabs = INI_TAB_LIST.filter(([k]) => k !== 'tickets' || bb.build).map(([k, l, ic]) => [k, l, ic,
756
757
  k === 'breakdown' && work.length ? el('span', { class: 'bdg g' }, String(work.length)) : k === 'tickets' ? el('span', { class: 'bdg g' }, String(work.filter((w) => w.src === 'jira').length)) : null]);
757
758
  const host = el('div', { class: 'calbody', 'data-ini-panel': tab });
@@ -761,16 +762,15 @@ export function mountInitiatives(pane, opts) {
761
762
  setTimeout(() => { if (token === P.seq && P.expect === ex) { P.ini = null; draw(); } }, 800);
762
763
  } else if (ex && ex.id === row.id) P.expect = null;
763
764
  const L = { ctx: ctxI, done: (change) => { P.expect = change || null; P.ini = null; P.boards = null; dropItems(); draw(); } };
764
- put(root,
765
- pathCrumbs(bb, row, el('div', { class: 'chips2' }, pbtn('outline', 'Dashboard', 'report', { onclick: () => ctxI.showTab('rep') }),
766
- ib('Actions for ' + row.id, 'more', { 'data-il-menu': row.id, 'aria-haspopup': 'menu', 'aria-expanded': 'false', title: 'Pause, cancel, merge or archive', onclick: (e) => pageMenu(e.currentTarget, L, row) }))),
765
+ const own = [{ kids: [icon('report', 'sm'), 'Dashboard'], attrs: { 'data-ini-act': 'dashboard' }, onPick: () => ctxI.showTab('rep') },
766
+ bb.build ? { kids: [icon('jira', 'sm'), (row.epics || []).length ? 'Jira epics ' + row.epics.join(', ') : 'Link Jira epics'], attrs: { 'data-ini': 'jira-epics' }, onPick: () => openEpics(bb, row) } : null].filter(Boolean);
767
+ put(root,
768
+ pathCrumbs(bb, row, el('div', { class: 'chips2' },
769
+ el('span', { class: 'chips2', 'data-ini': 'ini-chips' }, statusChip(row.status), el('span', { class: 'chips2', 'data-ini': 'warnings', 'aria-live': 'polite' }, ...warnings.map(warnChip))),
770
+ ib('Actions for ' + row.id, 'more', { 'data-il-menu': row.id, 'aria-haspopup': 'menu', 'aria-expanded': 'false', title: 'Dashboard, Jira epics, pause, cancel, merge or archive', onclick: (e) => pageMenu(e.currentTarget, L, row, own) }))),
767
771
  el('div', { class: 'calbody', 'data-ini-page': row.id },
768
772
  lifeBanner(L, row),
769
- lh('target', row.title || row.id, [row.id, bb.name, row.anchor || 'no anchor', row.owner || 'no owner', row.start || row.end ? day(row.start) + ' to ' + day(row.end) : 'no dates yet', row.horizon ? row.horizon[0].toUpperCase() + row.horizon.slice(1) : '', Math.round((Number(row.progress) || 0) * 100) + '% done'].filter(Boolean).join(' · ')),
770
- el('div', { class: 'chips2', 'data-ini': 'ini-chips' }, statusChip(row.status), chip(icon('bolt', 'sm'), plural(ep, 'epic') + ', ' + plural(work.length - ep, 'item')), buildTag(bb),
771
- bb.build ? pill({ 'data-ini': 'jira-epics', 'aria-haspopup': 'dialog', title: 'The Jira epics of this initiative', onclick: () => openEpics(bb, row) }, icon('jira', 'sm'), (row.epics || []).length ? 'Jira epics ' + row.epics.join(', ') : 'Link Jira epics') : null,
772
- row.lane ? tag(row.lane) : null, el('span', { class: 'chips2', 'data-ini': 'warnings', 'aria-live': 'polite' }, ...warnings.map(warnChip))),
773
- stepper, bb.self ? note(SELF_WHY) : null, strip,
773
+ stepper, bb.self ? note(SELF_WHY) : null,
774
774
  segTabs(tabs, tab, (k) => go({ view: 'ini', board: bb.id, id: row.id, tab: k }), 'Initiative views', { 'data-ini': 'ini-tabs' }),
775
775
  host));
776
776
  if (P.focusStep) { const f = stepper.querySelector('[data-ini-step="' + CSS.escape(P.focusStep) + '"]'); P.focusStep = null; if (f) f.focus(); }
@@ -809,16 +809,17 @@ export function mountInitiatives(pane, opts) {
809
809
  const n = root.querySelector('[data-ini-tool-card="' + CSS.escape(id) + '"]');
810
810
  if (n) { n.scrollIntoView({ behavior: 'smooth', block: 'start' }); n.classList.add('ini-flash'); setTimeout(() => n.classList.remove('ini-flash'), 1400); }
811
811
  }
812
- function moreTools(anchor, row, b, cat, kind) {
812
+ /* One Tools menu: this stage's tools first, then the other stages' tools under their stage names. */
813
+ function openTools(anchor, row, b, cat, kind, here) {
813
814
  const byCat = (P.ini && P.ini.toolsByCat) || {};
814
815
  const recOf = (c) => recordToolsFor(kind, c, b.build).map((id) => ({ id, label: RECORD_META[id][0], rec: true }));
815
- const seen = new Set([...(byCat[cat] || []), ...recOf(cat)].map((t) => t.id));
816
- const items = [];
816
+ const seen = new Set([...(byCat[cat] || []), ...recOf(cat), ...here].map((t) => t.id));
817
+ const items = here.length ? [{ group: 'For ' + row.stage }, ...here.map((t) => ({ kids: [icon(t.rec ? RECORD_META[t.id][1] : toolIcon(t.id), 'sm'), toolLabel(t)], current: P.loc.tool === t.id, attrs: { 'data-ini-tool': t.id }, onPick: () => pickTool(t.id) }))] : [];
817
818
  for (const s of wfOf(row, b)) {
818
819
  const ks = [...recOf(s.cat), ...(byCat[s.cat] || [])].filter((t) => !seen.has(t.id)); ks.forEach((t) => seen.add(t.id));
819
820
  if (ks.length) items.push({ group: s.name }, ...ks.map((t) => ({ kids: [icon(t.rec ? RECORD_META[t.id][1] : toolIcon(t.id), 'sm'), toolLabel(t)], attrs: { 'data-ini-moretool': t.id }, onPick: () => go({ ...P.loc, tab: 'overview', tool: t.id }) })));
820
821
  }
821
- openMenu(anchor, { label: 'More tools', items: items.length ? items : [{ label: 'No other tools for this workflow.', disabled: true, onPick: () => {} }] });
822
+ openMenu(anchor, { label: 'Tools', items: items.length ? items : [{ label: 'No tools for this workflow.', disabled: true, onPick: () => {} }] });
822
823
  }
823
824
  async function stepTo(stage) {
824
825
  const ini = P.ini; if (!ini) return;
@@ -40,11 +40,17 @@ Procedure:
40
40
  - `goal`: `{"text": "<the goal>", "jira": "<Jira's goal, or null>"}`. Always write the goal yourself from the sprint's tickets, in one sentence of at most 240 characters: what the sprint delivers when its tickets are done, naming the two or three largest outcomes. Do not copy Jira's sprint goal: it is often empty, too broad or incomplete. Put Jira's own goal in `"jira"` only as a reference, exactly as Jira writes it.
41
41
  - `summary`: two to four plain sentences on what the team is working on: what is done, what is moving, what is blocked. For a closed sprint, say if the goal was met and what moved to the next sprint.
42
42
  - `themes`: one entry per group of related tickets, at most 8, largest first: `{"title", "line", "tickets", "done", "blocked", "people", "initiative"}`. Group by initiative when the tickets belong to one (the initiative id from step 1); put tickets with no initiative in one theme such as "Fixes and upkeep" with `"initiative": null`. `line` is one sentence; `people` is the assignees' first names, comma-separated.
43
- - For a closed sprint, also `closed`: `{"done", "total", "carried", "carriedTo", "points"}` (`points` as "31 of 38" when the tickets have points).
44
- Use only what the tickets say. Never invent work, a date or a person. Save `{"sprints": [{"name", "id", "state", "goal", "summary", "themes", "closed"}]}` in the scratch folder, run `pmos jira summary --from <the file>`, and delete the file.
43
+ - For a closed sprint, also `closed`: `{"done", "total", "carried", "carriedTo", "points"}` (`points` as "31 of 38" when the tickets have points), `hit`: `"met"`, `"partly"` or `"missed"` (was the goal delivered), and `got`: one sentence on what got in the way, or leave it out when nothing did.
44
+ - For the active sprint, also `brief`, the sprint brief the PM reads first. Write it for a product manager: what matters, what is stuck, and what to do about it. PMOS counts the points, the flow, the velocity and the forecast itself, so do not repeat those as a list.
45
+ - `health`: `"ok"` (on track), `"warn"` (at risk) or `"bad"` (off track), from the share of points done against the share of the sprint gone, the blocked tickets and the velocity of the closed sprints.
46
+ - `verdict`: two or three sentences on where the sprint stands: on track or not, about how much will carry over at the team's usual pace, and which part of the work is exposed.
47
+ - `issues`: at most 6, worst first. One entry per blocked ticket (flagged, or a blocked or on-hold status) and per ticket that looks stuck (in progress or in review with no status change across reads, or a review queue that is building up), plus tickets with no estimate as one entry. Each is `{"sev": "bad" for blocked or "warn" for watch, "title", "keys", "initiative", "why", "by", "plan", "doneWhen"}`. `why` says why it matters for the goal. `plan` is two to four steps the PM can take to move the ticket to done: who to ask, the decision to make, the fallback if it does not work. `by` is when to act ("Decide today", "Check by Wed 15 Oct"). `doneWhen` is the outcome that closes it. Do not only report a problem: always propose the plan.
48
+ - `done`: at most 6 outcomes completed in the sprint, `{"title", "keys", "initiative", "line", "waiting"}`. Write the outcome, not the ticket title. Set `"waiting": true` when the ticket is done but not yet in production (a status such as "Ready for Prod" or a release that has not gone out).
49
+ - `asks`: at most 3 decisions or actions only the PM can take this week, each one sentence.
50
+ Use only what the tickets say. Never invent work, a date or a person. Save `{"sprints": [{"name", "id", "state", "goal", "summary", "themes", "closed", "hit", "got", "brief"}]}` in the scratch folder, run `pmos jira summary --from <the file>`, and delete the file.
45
51
  5. Record the people in People. For each assignee and reporter other than the PM, write one sighting per issue (People adds each new person at once): {"source": "jira", "handle": {"kind": "jira", "value": "<their Jira display name>"}, "name": "<display name>", "interaction": {"date": "<the issue's last update, YYYY-MM-DD>", "kind": "Jira", "text": "<Assignee|Reporter> of <KEY>: <summary, at most 150 characters>", "pointer": "jira · <KEY> · <url>", "initiative": "<the initiative id>"}}. Put all of them in one file {"sightings": [...]} in the scratch folder, run `pmos people record --file <that file>` once, and delete the file. An issue with no assignee gives no assignee sighting.
46
52
  6. Finish with the three sections of the output contract, one short line each:
47
- - Checked: the initiative, the number of tickets read, and each change (status, sprint, blocked). If nothing changed, say "no change". Add how many people you recorded, how many issues the sprint read saved per sprint, and which sprints got a goal and summary (and whose goal you wrote from the tickets).
53
+ - Checked: the initiative, the number of tickets read, and each change (status, sprint, blocked). If nothing changed, say "no change". Add how many people you recorded, how many issues the sprint read saved per sprint, and which sprints got a goal, summary and brief.
48
54
  - Not reached: each initiative whose search failed, with the reason, and the sprint read if it failed.
49
55
  - Needs you: tickets newly blocked, tickets the search did not return, and any refusal.
50
56