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 +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-ib-more-test.js +23 -4
- package/workspace/c3-ini-life-test.js +1 -1
- package/workspace/c3-initiatives-test.js +17 -9
- package/workspace/guide/initiative-boards.md +3 -3
- package/workspace/guide/jira.md +13 -1
- package/workspace/lib/engine/boards.js +51 -7
- package/workspace/lib/jira-status.js +46 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-boards.js +12 -3
- package/workspace/public/pm/c3-ib-sprints.js +142 -39
- package/workspace/public/pm/c3-ini-life.js +3 -3
- package/workspace/public/pm/c3-initiatives.css +18 -0
- package/workspace/public/pm/c3-initiatives.js +23 -22
- package/workspace/skills/jira-sync.skill +9 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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
|
|
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')
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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-
|
|
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
|
-
|
|
164
|
-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
|
package/workspace/guide/jira.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
9
|
+
"version": "0.214.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
5
|
-
* status, category: todo|doing|done, initiative }], counts, goal: { from: 'tickets', text,
|
|
6
|
-
* themes: [{ title, line, tickets, done, blocked, people, initiative }],
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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,
|
|
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
|
-
|
|
23
|
-
|
|
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 ?
|
|
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'),
|
|
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 ?
|
|
36
|
-
t.people ? chip(icon('user', 'sm'), t.people) : null,
|
|
37
|
-
|
|
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
|
|
53
|
-
const
|
|
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
|
|
59
|
-
|
|
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
|
-
|
|
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: '
|
|
67
|
-
|
|
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 + ' ·
|
|
73
|
-
...
|
|
74
|
-
|
|
75
|
-
: 'No
|
|
76
|
-
active ?
|
|
77
|
-
|
|
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'], ['
|
|
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)
|
|
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);
|
|
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
|
-
|
|
745
|
-
|
|
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
|
|
753
|
-
const
|
|
754
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
|
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
|
|