create-pmos 0.213.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/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-initiatives.css +18 -0
- 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-14T03:
|
|
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-14T03:
|
|
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-14T03:
|
|
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 ── */
|
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));
|
|
@@ -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)}
|
|
@@ -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
|
|