create-pmos 0.205.0 → 0.207.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 +2 -2
- 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/payload/state/log.md +1 -1
- package/workspace/c3-ib-more-test.js +7 -1
- package/workspace/guide/jira.md +18 -1
- package/workspace/lib/engine/boards.js +46 -2
- package/workspace/lib/jira-cli.js +26 -3
- package/workspace/lib/jira-status.js +131 -6
- package/workspace/lib/routes-boards.js +7 -3
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-boards.js +11 -1
- package/workspace/public/pm/c3-ib-sprints.js +59 -13
- package/workspace/public/pm/c3-initiatives.css +5 -0
- package/workspace/skills/jira-sync.skill +11 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.207.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
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-10-
|
|
1
|
+
kernel-source: pmos@b515557
|
|
2
|
+
assembled: 2026-10-14
|
|
3
3
|
mode: vendor
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-14T01:10:19Z"}
|
|
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-14T01:10:19Z"}
|
|
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-14T01:10:19Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/payload/state/log.md
CHANGED
|
@@ -43,7 +43,13 @@ const { harness } = require('./c3-ini-harness');
|
|
|
43
43
|
await open('board:idpf:sprints');
|
|
44
44
|
await until(() => st(() => !!document.querySelector('[data-inb="sprints-source"]')));
|
|
45
45
|
ok('SP1 the active sprint header says Jira, read-only, and there is no banner', /Jira, read-only/.test(await text('[data-inb="sprints-source"]')) && await st(() => !!document.querySelector('[data-inb-state="active"] [data-inb="sprints-source"]') && !document.querySelector('.fold [data-inb="sprints-source"], .fold[data-inb="sprints-source"]')));
|
|
46
|
-
ok('SP2 the active sprint is drawn with its counts; future and closed sprints are listed', await st(() => !!document.querySelector('[data-inb-state="active"]') && !!document.querySelector('[data-inb-state="future"]') && !!document.querySelector('[data-inb-state="closed"]') && ((m) => !!m && Number(m[1])
|
|
46
|
+
ok('SP2 the active sprint is drawn with its counts; future and closed sprints are listed', await st(() => !!document.querySelector('[data-inb-state="active"]') && !!document.querySelector('[data-inb-state="future"]') && !!document.querySelector('[data-inb-state="closed"]') && ((m) => !!m && Number(m[1]) === 12 && Number(m[2]) + Number(m[3]) + Number(m[4]) === 12)(/Items(\d+):(\d+)done,(\d+)inprogress,(\d+)todo/.exec(document.querySelector('[data-inb="sprint-counts"]').textContent.replace(/\s+/g, '')))));
|
|
47
|
+
// sprint-goal-summary: the goal (from Jira, or written from the tickets and why), the team summary, closed sprints read once
|
|
48
|
+
ok('SP4 the sprint goal is written from the tickets, with Jira\'s goal only as a reference', await st(() => { const g = document.querySelector('[data-inb="sprint-summary"] [data-inb="sprint-goal"]'); return !!g && g.dataset.inbGoal === 'tickets' && /SPRINT GOAL · from the tickets/.test(g.textContent) && /In Jira: “Q4 delivery”/.test(g.textContent) && !/from Jira/.test(g.textContent); })
|
|
49
|
+
&& /from the tickets/.test(await text('[data-inb-state="future"] [data-inb="sprint-goal"]')) && !(await text('[data-inb-state="future"] [data-inb="sprint-goal"]')).includes('In Jira') && /from the tickets/.test(await text('[data-inb-state="closed"] [data-inb="sprint-goal"]')));
|
|
50
|
+
ok('SP5 what the team is working on: a row per theme, one with no initiative', await st(() => document.querySelectorAll('[data-inb="sprint-themes"] [data-inb-theme]').length === 2 && !!document.querySelector('[data-inb-theme="none"]') && /No initiative/.test(document.querySelector('[data-inb-theme="none"]').textContent)));
|
|
51
|
+
ok('SP6 a closed sprint shows done of total, carried, and that it was read and summarised once', /5 of 7 done/.test(await text('[data-inb-state="closed"]')) && /2 carried to/.test(await text('[data-inb-state="closed"]'))
|
|
52
|
+
&& /Read and summarised once on 29 Sep\. PMOS does not read a closed sprint again\./.test(await text('[data-inb-state="closed"] [data-inb="sprint-read-once"]')) && /Closed sprints/.test(await text('[data-inb="sprints-how"]')));
|
|
47
53
|
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'))));
|
|
48
54
|
|
|
49
55
|
/* ── an initiative's six tabs ── */
|
package/workspace/guide/jira.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Jira: tickets drafted from the PRD, and their status"
|
|
4
4
|
description: "How to draft Jira tickets from a PRD in your ticket template, push the ones that are ready, and see their status, sprint and progress on the initiative's Jira tickets tab."
|
|
5
|
-
timestamp: 2026-10-
|
|
5
|
+
timestamp: 2026-10-14
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Jira: tickets drafted from the PRD, and their status
|
|
@@ -119,6 +119,22 @@ pmos jira query --sprints
|
|
|
119
119
|
pmos jira sync --sprints --from <file|-> [--query "<jql>"]
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
+
## Each sprint's goal and summary
|
|
123
|
+
|
|
124
|
+
The check then writes a goal and a short summary for each sprint, from the tickets it read. The Sprints tab shows them above the sprint's items, with one row for each group of related work.
|
|
125
|
+
|
|
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
|
+
- When Jira has a sprint goal, the tab shows it under the goal as a reference: **In Jira: "…"**.
|
|
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.
|
|
130
|
+
|
|
131
|
+
The check writes `<state>/tracker/jira/_sprint-notes.md` with:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
pmos jira summary
|
|
135
|
+
pmos jira summary --from <file|-> [--dry-run]
|
|
136
|
+
```
|
|
137
|
+
|
|
122
138
|
## Link an initiative to its Jira epics
|
|
123
139
|
|
|
124
140
|
Your team may already plan in Jira, with an epic for each piece of work. Then link the initiative to its epics. You do not have to draft or push tickets.
|
|
@@ -163,6 +179,7 @@ The morning brief, the day check and requirement gathering use it to tie a Jira
|
|
|
163
179
|
- `pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run]`
|
|
164
180
|
- `pmos jira query --sprints`
|
|
165
181
|
- `pmos jira sync --sprints --from <file|-> [--query "<jql>"] [--dry-run]`
|
|
182
|
+
- `pmos jira summary [--from <file|->] [--dry-run]`
|
|
166
183
|
- `pmos jira which <KEY>`
|
|
167
184
|
|
|
168
185
|
Every command takes `--json` and `--root <dir>`.
|
|
@@ -15,7 +15,7 @@
|
|
|
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?)
|
|
18
|
+
* sprintsOf(snapshots, onBoard, whole?, notes?) → { source, readAt, sprints } (notes: each sprint's goal and summary, sprint-goal-summary)
|
|
19
19
|
* INI_TEMPLATES · isBuildStage · templateBuilds · boardSettings(board) the per-initiative workflows (console3-initiatives-parity)
|
|
20
20
|
* workflowOf(board, id, folded) → the stages an initiative runs: its own (ini-workflow), else the board's
|
|
21
21
|
* applyIniWorkflow(board, wfNow, stage, template, stages, moveTo) → { ok, template, workflow, moved } | { ok:false, status, error }
|
|
@@ -582,7 +582,7 @@ function counts(rows, workflow) {
|
|
|
582
582
|
* lists EVERY issue Jira holds in it, whoever it is assigned to; an issue no initiative of the board holds has initiative null
|
|
583
583
|
* (jira-sprint-scope). Without it, a sprint lists only the board's own tickets.
|
|
584
584
|
*/
|
|
585
|
-
function sprintsOf(snapshots, onBoard = () => true, whole = null) {
|
|
585
|
+
function sprintsOf(snapshots, onBoard = () => true, whole = null, notes = null) {
|
|
586
586
|
const byName = new Map();
|
|
587
587
|
let readAt = null;
|
|
588
588
|
let any = false;
|
|
@@ -625,6 +625,50 @@ function sprintsOf(snapshots, onBoard = () => true, whole = null) {
|
|
|
625
625
|
for (const i of sp.issues) sp.counts[i.category]++;
|
|
626
626
|
}
|
|
627
627
|
if (widened && whole.fetchedAt && (!readAt || whole.fetchedAt < readAt)) readAt = whole.fetchedAt; // the oldest read is the honest age
|
|
628
|
+
// The goal and summary the Jira status check wrote (sprint-goal-summary). A closed sprint is summarised once and not read again,
|
|
629
|
+
// so one the board's initiatives worked in is listed from its note even when none of the board's tickets is in it now.
|
|
630
|
+
const nl = notes && Array.isArray(notes.notes) ? notes.notes : [];
|
|
631
|
+
const same = (a, b) => ((a.id && b.id) ? String(a.id) === String(b.id) : String(a.name) === String(b.name));
|
|
632
|
+
if (any) {
|
|
633
|
+
const iniOf = new Map();
|
|
634
|
+
for (const s of snapshots || []) {
|
|
635
|
+
if (!s || !onBoard(s.initiative)) continue;
|
|
636
|
+
for (const t of s.tickets instanceof Map ? s.tickets.values() : Array.isArray(s.tickets) ? s.tickets : []) if (t && t.key) iniOf.set(t.key, s.initiative);
|
|
637
|
+
}
|
|
638
|
+
for (const n of nl) {
|
|
639
|
+
if (n.state !== 'closed' || byName.has(n.name)) continue;
|
|
640
|
+
if (!(n.themes || []).some((t) => t && t.initiative && onBoard(t.initiative))) continue;
|
|
641
|
+
const issues = wt.filter((t) => t && t.key && t.sprint && same(t.sprint, n)).map((t) => {
|
|
642
|
+
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 };
|
|
644
|
+
});
|
|
645
|
+
const counts = { todo: 0, doing: 0, done: 0 };
|
|
646
|
+
for (const i of issues) counts[i.category]++;
|
|
647
|
+
byName.set(n.name, { name: n.name, state: 'closed', start: n.start || null, end: n.end || null, issues, counts });
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
const jiraGoal = (sp) => {
|
|
651
|
+
for (const t of wt) if (t && t.sprint && String(t.sprint.name) === sp.name && t.sprint.goal) return String(t.sprint.goal);
|
|
652
|
+
for (const s of snapshots || []) for (const t of s && s.tickets instanceof Map ? s.tickets.values() : s && Array.isArray(s.tickets) ? s.tickets : []) if (t && t.sprint && String(t.sprint.name) === sp.name && t.sprint.goal) return String(t.sprint.goal);
|
|
653
|
+
return null;
|
|
654
|
+
};
|
|
655
|
+
for (const sp of byName.values()) {
|
|
656
|
+
const n = nl.find((x) => x.name === sp.name);
|
|
657
|
+
const jg = jiraGoal(sp);
|
|
658
|
+
if (n) {
|
|
659
|
+
sp.goal = { from: 'tickets', text: n.goal.text, jira: n.goal.jira || jg || null }; // always from the tickets; Jira's goal is a reference
|
|
660
|
+
sp.summary = n.summary;
|
|
661
|
+
sp.themes = (n.themes || []).map((t) => ({ title: t.title, line: t.line || '', tickets: t.tickets || 0, done: t.done || 0, blocked: t.blocked || 0, people: t.people || '', initiative: t.initiative || null }));
|
|
662
|
+
sp.writtenAt = n.writtenAt || null;
|
|
663
|
+
sp.summarisedTickets = Number.isInteger(n.tickets) ? n.tickets : null;
|
|
664
|
+
sp.closed = sp.state === 'closed' && n.closed ? { ...n.closed } : null;
|
|
665
|
+
sp.frozen = sp.state === 'closed' && n.state === 'closed';
|
|
666
|
+
} else {
|
|
667
|
+
sp.goal = null;
|
|
668
|
+
sp.jiraGoal = jg;
|
|
669
|
+
sp.summary = null; sp.themes = []; sp.writtenAt = null; sp.summarisedTickets = null; sp.closed = null; sp.frozen = false;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
628
672
|
const order = { active: 0, future: 1, closed: 2 };
|
|
629
673
|
const sprints = [...byName.values()].sort((a, b) => ((order[a.state] ?? 3) - (order[b.state] ?? 3)) || a.name.localeCompare(b.name));
|
|
630
674
|
for (const sp of sprints) sp.issues.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }));
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run] a saved search → the status snapshot
|
|
13
13
|
* pmos jira query --sprints the Jira search for every issue in the sprints the tickets are in
|
|
14
14
|
* pmos jira sync --sprints --from <file|-> [--query "<jql>"] a saved whole-sprint search → the Sprints tab (jira-sprint-scope)
|
|
15
|
+
* pmos jira summary [--from <file|->] [--dry-run] the sprints to summarise, or save their goal and summary (sprint-goal-summary)
|
|
15
16
|
* pmos jira which <KEY> the initiative a Jira key belongs to
|
|
16
17
|
* every command: [--json] [--root <dir>]
|
|
17
18
|
*
|
|
@@ -27,9 +28,9 @@ const jiraTickets = require('./jira-tickets.js');
|
|
|
27
28
|
const planItems = require('./plan-items.js');
|
|
28
29
|
const guard = require('./pm-guard.js');
|
|
29
30
|
|
|
30
|
-
const COMMANDS = { jira: ['draft', 'show', 'epic', 'approve', 'pending', 'record', 'query', 'sync', 'which'] };
|
|
31
|
+
const COMMANDS = { jira: ['draft', 'show', 'epic', 'approve', 'pending', 'record', 'query', 'sync', 'summary', 'which'] };
|
|
31
32
|
const USAGE = [
|
|
32
|
-
'usage: pmos jira draft|show|epic|approve|pending|record|query|sync|which — Jira tickets from the PRD, and their status',
|
|
33
|
+
'usage: pmos jira draft|show|epic|approve|pending|record|query|sync|summary|which — Jira tickets from the PRD, and their status',
|
|
33
34
|
' pmos jira draft <initiative> [--dry-run] draft one ticket per PRD item, in the ticket template',
|
|
34
35
|
' pmos jira show <initiative> every ticket, what needs you, and its status in Jira',
|
|
35
36
|
' pmos jira epic <initiative> new|<KEY or link> create a new epic at the first push, or use an existing one',
|
|
@@ -40,6 +41,8 @@ const USAGE = [
|
|
|
40
41
|
' pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run] save the status read from Jira',
|
|
41
42
|
' pmos jira query --sprints the Jira search for every issue in those tickets\' sprints',
|
|
42
43
|
' pmos jira sync --sprints --from <file|-> [--query "<jql>"] save every issue of the sprints, for the Sprints tab',
|
|
44
|
+
' pmos jira summary the sprints that need a goal and summary written',
|
|
45
|
+
' pmos jira summary --from <file|-> [--dry-run] save each sprint\'s goal, summary and themes (a closed sprint is kept once written)',
|
|
43
46
|
' pmos jira which <KEY> the initiative a Jira key belongs to',
|
|
44
47
|
' every command: [--json] [--root <dir>]',
|
|
45
48
|
'This command never calls Jira. Your agent reads and writes Jira with your own connection.',
|
|
@@ -99,7 +102,7 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
|
|
|
99
102
|
if (flags.sprints && verb !== 'query' && verb !== 'sync') return fail('--sprints goes with query or sync', 2);
|
|
100
103
|
if (verb === 'query' && flags.sprints) {
|
|
101
104
|
const q = jiraStatus.sprintQuery(root);
|
|
102
|
-
return out(q, [q.linked ? q.query : 'no Jira reading has a sprint yet — run the initiatives\' searches first']);
|
|
105
|
+
return out(q, [q.linked ? q.query : q.kept.length ? 'every sprint is closed and summarised once — nothing to read' : 'no Jira reading has a sprint yet — run the initiatives\' searches first']);
|
|
103
106
|
}
|
|
104
107
|
if (verb === 'sync' && flags.sprints) {
|
|
105
108
|
if (!flags.from) return need('--sprints --from <file|->');
|
|
@@ -113,6 +116,26 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
|
|
|
113
116
|
if (!r.dryRun) lines.push(`snapshot ${r.snapshot}`);
|
|
114
117
|
return out(r, lines);
|
|
115
118
|
}
|
|
119
|
+
if (verb === 'summary') {
|
|
120
|
+
if (!flags.from) {
|
|
121
|
+
const r = jiraStatus.sprintsToSummarise(root);
|
|
122
|
+
if (!r.read) return out(r, ['no whole-sprint read yet — run pmos jira query --sprints and sync --sprints first']);
|
|
123
|
+
const lines = r.sprints.map((s) => `${s.name}\t${s.state}\t${s.tickets} ${s.tickets === 1 ? 'issue' : 'issues'}\t${s.jiraGoal ? `goal in Jira: ${s.jiraGoal}` : 'no goal in Jira'}`);
|
|
124
|
+
if (!lines.length) lines.push('no sprint needs a summary');
|
|
125
|
+
for (const k of r.kept) lines.push(`${k}\tclosed\tkept, summarised once`);
|
|
126
|
+
return out(r, lines);
|
|
127
|
+
}
|
|
128
|
+
let raw;
|
|
129
|
+
try { raw = JSON.parse(readInput(flags.from, cwd, stdin)); }
|
|
130
|
+
catch (e) { return fail(`could not read ${flags.from} as JSON — ${String((e && e.message) || e).slice(0, 160)}`); }
|
|
131
|
+
const r = jiraStatus.saveSprintNotes(root, raw, { dryRun: !!flags['dry-run'], now });
|
|
132
|
+
if (!r.ok) return fail(r.error);
|
|
133
|
+
const lines = [`sprint summaries · saved ${r.saved.length}${r.kept.length ? ` · kept ${r.kept.length} closed, summarised once` : ''}${r.dryRun ? ' · dry run, nothing written' : ''}`];
|
|
134
|
+
for (const n of r.saved) lines.push(` saved ${n}`);
|
|
135
|
+
for (const n of r.kept) lines.push(` kept ${n} (closed; its first summary stays)`);
|
|
136
|
+
if (!r.dryRun && r.saved.length) lines.push(`file ${r.file}`);
|
|
137
|
+
return out(r, lines);
|
|
138
|
+
}
|
|
116
139
|
if (verb === 'query') {
|
|
117
140
|
if (initiative) {
|
|
118
141
|
const q = jiraStatus.queryFor(root, initiative);
|
|
@@ -17,9 +17,12 @@
|
|
|
17
17
|
* are in, whoever holds them (jira-sprint-scope); linked false when no reading has a sprint
|
|
18
18
|
* syncSprints(root, raw, opts) → the whole-sprint snapshot, written to <state>/tracker/jira/_sprints.md
|
|
19
19
|
* readSprints(root) → { fetchedAt, query, tickets: Map<key, reading> } | null
|
|
20
|
+
* sprintsToSummarise(root) → { ok, sprints, kept } the sprints of the whole-sprint read that need a goal and summary
|
|
21
|
+
* saveSprintNotes(root, raw, opts) → each sprint's goal, summary and themes, written to <state>/tracker/jira/_sprint-notes.md
|
|
22
|
+
* readSprintNotes(root) → { writtenAt, notes: [note] } | null (sprint-goal-summary)
|
|
20
23
|
*
|
|
21
24
|
* A READING is { key, summary, status, category: todo|doing|done, sprint: { name, state: active|future|closed, id?, start?, end?,
|
|
22
|
-
* completed? } | null, subtasks: { done, total }, fixVersion: [names], points: number | null, resolved: YYYY-MM-DD | null,
|
|
25
|
+
* completed?, goal? } | null, subtasks: { done, total }, fixVersion: [names], points: number | null, resolved: YYYY-MM-DD | null,
|
|
23
26
|
* flagged, reason, url }. The sprint's dates, `points` and `resolved` feed the sprint burndown (lib/stage-tools.js); a
|
|
24
27
|
* reading saved before they were carried has none of them, and the burndown says it cannot be drawn. A ticket the search did not return keeps its last reading, marked
|
|
25
28
|
* stale. Titles and statuses only: never a description, a comment or a person.
|
|
@@ -100,6 +103,8 @@ function sprintOne(v) {
|
|
|
100
103
|
const d = dayOf(from.map((f) => v[f]).find((x) => x != null));
|
|
101
104
|
if (d) out[k] = d;
|
|
102
105
|
}
|
|
106
|
+
const goal = clip(oneLine(v.goal), 300); // Jira's sprint goal, often empty or broad (sprint-goal-summary)
|
|
107
|
+
if (goal) out.goal = goal;
|
|
103
108
|
return out;
|
|
104
109
|
}
|
|
105
110
|
/** An ISO date or date-time → YYYY-MM-DD (the date as written, never shifted by a zone), else null. */
|
|
@@ -286,7 +291,8 @@ function sync(root, initiative, raw, { query, dryRun = false, now } = {}) {
|
|
|
286
291
|
const SPRINTS_FILE = '_sprints';
|
|
287
292
|
const jqlName = (n) => `"${String(n).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
288
293
|
function sprintQuery(root) {
|
|
289
|
-
const
|
|
294
|
+
const frozen = frozenNotes(root);
|
|
295
|
+
const seen = new Map(); const kept = new Set();
|
|
290
296
|
for (const q of linkedInitiatives(root).filter((x) => x.linked)) {
|
|
291
297
|
const snap = readSnapshot(root, q.initiative);
|
|
292
298
|
if (!snap) continue;
|
|
@@ -294,20 +300,27 @@ function sprintQuery(root) {
|
|
|
294
300
|
const sp = t && t.sprint;
|
|
295
301
|
if (!sp || !sp.name) continue;
|
|
296
302
|
const k = sp.id || sp.name;
|
|
303
|
+
if (frozen.some((f) => sameSprint(f, sp))) { kept.add(sp.name); continue; } // a closed sprint is read once (sprint-goal-summary)
|
|
297
304
|
if (!seen.has(k)) seen.set(k, { name: sp.name, state: sp.state || null, ...(sp.id ? { id: sp.id } : {}) });
|
|
298
305
|
}
|
|
299
306
|
}
|
|
300
307
|
const sprints = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
|
301
|
-
if (!sprints.length) return { ok: true, linked: false, sprints, query: null };
|
|
302
|
-
return { ok: true, linked: true, sprints, query: `sprint in (${sprints.map((s) => s.id || jqlName(s.name)).join(', ')}) ORDER BY key` };
|
|
308
|
+
if (!sprints.length) return { ok: true, linked: false, sprints, kept: [...kept], query: null };
|
|
309
|
+
return { ok: true, linked: true, sprints, kept: [...kept], query: `sprint in (${sprints.map((s) => s.id || jqlName(s.name)).join(', ')}) ORDER BY key` };
|
|
303
310
|
}
|
|
304
|
-
/** Save a whole-sprint read. The read replaces the last one whole: an issue moved out of a sprint is gone from it.
|
|
311
|
+
/** Save a whole-sprint read. The read replaces the last one whole: an issue moved out of a sprint is gone from it. A closed
|
|
312
|
+
* sprint that has its summary is not searched again, so its issues are kept from the last read. */
|
|
305
313
|
function syncSprints(root, raw, { query, dryRun = false, now } = {}) {
|
|
306
314
|
const n = normalize(raw);
|
|
307
315
|
if (n.error) return { ok: false, error: n.error };
|
|
308
316
|
const kept = n.issues.filter((i) => i.sprint && i.sprint.name);
|
|
309
317
|
const fetchedAt = nowIso(now);
|
|
310
318
|
const prev = readSprints(root);
|
|
319
|
+
const frozen = frozenNotes(root);
|
|
320
|
+
if (prev && frozen.length) {
|
|
321
|
+
const got = new Set(kept.map((i) => i.key));
|
|
322
|
+
for (const t of prev.tickets.values()) if (!got.has(t.key) && t.sprint && frozen.some((f) => sameSprint(f, t.sprint))) kept.push(t);
|
|
323
|
+
}
|
|
311
324
|
const bySprint = {};
|
|
312
325
|
for (const i of kept) bySprint[i.sprint.name] = (bySprint[i.sprint.name] || 0) + 1;
|
|
313
326
|
const result = { ok: true, read: kept.length, ignored: n.issues.length - kept.length, skipped: n.skipped, sprints: bySprint,
|
|
@@ -322,6 +335,117 @@ function syncSprints(root, raw, { query, dryRun = false, now } = {}) {
|
|
|
322
335
|
}
|
|
323
336
|
function readSprints(root) { return readSnapshot(root, SPRINTS_FILE); }
|
|
324
337
|
|
|
338
|
+
// ── the goal and summary of each sprint (sprint-goal-summary) ─────────────────────────────────────────
|
|
339
|
+
// The Jira status check writes them from the tickets it read. The goal is always written from the tickets: Jira's sprint goal is
|
|
340
|
+
// often empty, too broad or incomplete (the PM, 2026-10-14), so it is kept only as a reference (goal.jira). A closed sprint's
|
|
341
|
+
// note is written once and kept; its sprint is not searched again.
|
|
342
|
+
const NOTES_FILE = '_sprint-notes';
|
|
343
|
+
const INI_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
|
|
344
|
+
const sameSprint = (a, b) => !!a && !!b && ((a.id && b.id) ? String(a.id) === String(b.id) : String(a.name) === String(b.name));
|
|
345
|
+
function readSprintNotes(root) {
|
|
346
|
+
let text;
|
|
347
|
+
try { text = fs.readFileSync(snapshotPath(root, NOTES_FILE), 'utf8'); } catch { return null; }
|
|
348
|
+
const m = /```json\n([\s\S]*?)\n```/.exec(text);
|
|
349
|
+
if (!m) return null;
|
|
350
|
+
let data;
|
|
351
|
+
try { data = JSON.parse(m[1]); } catch { return null; }
|
|
352
|
+
const notes = (Array.isArray(data.sprints) ? data.sprints : []).filter((x) => x && isStr(x.name) && x.goal && isStr(x.summary));
|
|
353
|
+
return { writtenAt: isStr(data.written_at) ? data.written_at : null, notes };
|
|
354
|
+
}
|
|
355
|
+
function frozenNotes(root) { const r = readSprintNotes(root); return r ? r.notes.filter((x) => x.state === 'closed') : []; }
|
|
356
|
+
const count = (v, max) => (Number.isInteger(v) && v >= 0 && v <= (max == null ? 100000 : max) ? v : null);
|
|
357
|
+
const text = (v, n) => clip(oneLine(v), n);
|
|
358
|
+
/** One sprint's note from the agent → { note } | { error }. Nothing is guessed: a missing required field refuses the note. */
|
|
359
|
+
function noteOne(x) {
|
|
360
|
+
if (!x || typeof x !== 'object') return { error: 'a sprint note is not an object' };
|
|
361
|
+
const name = text(x.name, 80);
|
|
362
|
+
if (!name) return { error: 'a sprint note has no name' };
|
|
363
|
+
const state = String(x.state || '').toLowerCase();
|
|
364
|
+
if (!SPRINT_STATES.includes(state)) return { error: `${name}: state is active, future or closed` };
|
|
365
|
+
const g = x.goal && typeof x.goal === 'object' ? x.goal : null;
|
|
366
|
+
const gt = g ? text(g.text, 240) : '';
|
|
367
|
+
if (!gt) return { error: `${name}: the goal has no text — write it from the sprint's tickets` };
|
|
368
|
+
const goal = { from: 'tickets', text: gt };
|
|
369
|
+
const j = text(g.jira, 300);
|
|
370
|
+
if (j) goal.jira = j; // Jira's own sprint goal, kept for reference only
|
|
371
|
+
const summary = text(x.summary, 900);
|
|
372
|
+
if (!summary) return { error: `${name}: the summary is empty` };
|
|
373
|
+
const themes = [];
|
|
374
|
+
for (const t of Array.isArray(x.themes) ? x.themes.slice(0, 8) : []) {
|
|
375
|
+
const title = t && text(t.title, 80);
|
|
376
|
+
if (!title) return { error: `${name}: a theme has no title` };
|
|
377
|
+
const tickets = count(t.tickets);
|
|
378
|
+
if (tickets == null) return { error: `${name}: theme "${title}" needs a ticket count` };
|
|
379
|
+
const ini = t.initiative == null || t.initiative === '' ? null : String(t.initiative).trim();
|
|
380
|
+
if (ini != null && !INI_RE.test(ini)) return { error: `${name}: theme "${title}" has a bad initiative id` };
|
|
381
|
+
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
|
+
}
|
|
383
|
+
const note = { name, state, goal, summary, themes };
|
|
384
|
+
const id = isStr(x.id) || Number.isInteger(x.id) ? String(x.id) : '';
|
|
385
|
+
if (/^\d{1,12}$/.test(id)) note.id = id;
|
|
386
|
+
if (state === 'closed' && x.closed && typeof x.closed === 'object') {
|
|
387
|
+
const c = x.closed;
|
|
388
|
+
note.closed = { done: count(c.done) || 0, total: count(c.total) || 0, carried: count(c.carried) || 0, carriedTo: text(c.carriedTo, 80) || null, points: text(c.points, 40) || null };
|
|
389
|
+
}
|
|
390
|
+
return { note };
|
|
391
|
+
}
|
|
392
|
+
/** The sprints of the whole-sprint read that need a note: every active and future one, and each closed one not yet noted. */
|
|
393
|
+
function sprintsToSummarise(root) {
|
|
394
|
+
const whole = readSprints(root);
|
|
395
|
+
const frozen = frozenNotes(root);
|
|
396
|
+
const by = new Map();
|
|
397
|
+
for (const t of whole ? whole.tickets.values() : []) {
|
|
398
|
+
const sp = t.sprint;
|
|
399
|
+
if (!sp || !sp.name) continue;
|
|
400
|
+
if (!by.has(sp.name)) by.set(sp.name, { name: sp.name, ...(sp.id ? { id: sp.id } : {}), state: sp.state, tickets: 0, jiraGoal: null });
|
|
401
|
+
const o = by.get(sp.name);
|
|
402
|
+
o.tickets++;
|
|
403
|
+
if (!o.jiraGoal && sp.goal) o.jiraGoal = sp.goal;
|
|
404
|
+
}
|
|
405
|
+
const all = [...by.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
|
|
406
|
+
const isKept = (s) => frozen.some((f) => sameSprint(f, s)); // the note was written when Jira said closed
|
|
407
|
+
return { ok: true, read: !!whole, sprints: all.filter((s) => !isKept(s)), kept: all.filter(isKept).map((s) => s.name) };
|
|
408
|
+
}
|
|
409
|
+
/** Save the notes the agent wrote. A closed sprint already noted keeps its first note. A bad note refuses the whole file. */
|
|
410
|
+
function saveSprintNotes(root, raw, { dryRun = false, now } = {}) {
|
|
411
|
+
const list = Array.isArray(raw) ? raw : raw && Array.isArray(raw.sprints) ? raw.sprints : null;
|
|
412
|
+
if (!list) return { ok: false, error: 'the file holds no "sprints": [...] list' };
|
|
413
|
+
const got = [];
|
|
414
|
+
for (const x of list) { const r = noteOne(x); if (r.error) return { ok: false, error: r.error }; got.push(r.note); }
|
|
415
|
+
const prev = readSprintNotes(root);
|
|
416
|
+
const notes = prev ? prev.notes.slice() : [];
|
|
417
|
+
const whole = readSprints(root);
|
|
418
|
+
const wt = whole ? [...whole.tickets.values()] : [];
|
|
419
|
+
const writtenAt = nowIso(now);
|
|
420
|
+
const saved = []; const kept = [];
|
|
421
|
+
for (const n of got) {
|
|
422
|
+
const i = notes.findIndex((o) => sameSprint(o, n));
|
|
423
|
+
if (i >= 0 && notes[i].state === 'closed') { kept.push(n.name); continue; }
|
|
424
|
+
const mine = wt.filter((t) => t.sprint && sameSprint(t.sprint, n));
|
|
425
|
+
const sp = mine.length ? mine[0].sprint : {};
|
|
426
|
+
const out = { ...n, writtenAt, tickets: mine.length, ...(sp.start ? { start: sp.start } : {}), ...(sp.end ? { end: sp.end } : {}), ...(sp.completed ? { completed: sp.completed } : {}) };
|
|
427
|
+
if (!out.id && sp.id) out.id = sp.id;
|
|
428
|
+
if (i >= 0) notes[i] = out; else notes.push(out);
|
|
429
|
+
saved.push(n.name);
|
|
430
|
+
}
|
|
431
|
+
const result = { ok: true, saved, kept, file: snapshotRel(root, NOTES_FILE), dryRun: !!dryRun };
|
|
432
|
+
if (dryRun || !saved.length) return result;
|
|
433
|
+
const file = snapshotPath(root, NOTES_FILE);
|
|
434
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
435
|
+
const rows = notes.map((o) => `| ${cell(o.name)} | ${cell(o.state)} | ${cell(o.goal.text)} | ${o.goal.jira ? cell(o.goal.jira) : '—'} | ${cell(o.writtenAt)} |`);
|
|
436
|
+
const body = [
|
|
437
|
+
'---', 'type: Reference', 'title: "Jira sprints: goal and summary"', `timestamp: ${writtenAt}`, '---', '',
|
|
438
|
+
'# Jira sprints: goal and summary', '',
|
|
439
|
+
'Written by the Jira status check from the tickets it read, read-only. The Sprints tab of a board shows it. A closed sprint is written once and kept. Written by `pmos jira summary`; do not edit by hand.', '',
|
|
440
|
+
'| Sprint | State | Goal (from the tickets) | Goal in Jira | Written |', '|---|---|---|---|---|', ...rows, '',
|
|
441
|
+
'```json', JSON.stringify({ written_at: writtenAt, sprints: notes }, null, 0), '```', '',
|
|
442
|
+
].join('\n');
|
|
443
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
444
|
+
fs.writeFileSync(tmp, body);
|
|
445
|
+
fs.renameSync(tmp, file);
|
|
446
|
+
return { ...result, writtenAt };
|
|
447
|
+
}
|
|
448
|
+
|
|
325
449
|
// ── J13 · which initiative ─────────────────────────────────────────────────────────────────────────
|
|
326
450
|
function which(root, key) {
|
|
327
451
|
const k = String(key || '').trim().toUpperCase();
|
|
@@ -335,4 +459,5 @@ function which(root, key) {
|
|
|
335
459
|
return { ok: true, key: k, initiatives: hits };
|
|
336
460
|
}
|
|
337
461
|
|
|
338
|
-
module.exports = { queryFor, linkedInitiatives, normalize, sync, readSnapshot, which, sprintLabel, snapshotPath, snapshotRel, sprintQuery, syncSprints, readSprints, SPRINTS_FILE
|
|
462
|
+
module.exports = { queryFor, linkedInitiatives, normalize, sync, readSnapshot, which, sprintLabel, snapshotPath, snapshotRel, sprintQuery, syncSprints, readSprints, SPRINTS_FILE,
|
|
463
|
+
sprintsToSummarise, saveSprintNotes, readSprintNotes, sameSprint, NOTES_FILE };
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* GET /api/boards/columns?ws&board&q&owner&cap → { ok, columns: [{ stage, cat, hint, count, rows }] }
|
|
10
10
|
* GET /api/boards/initiative?ws&board&id → { ok, row, board, history, tools }
|
|
11
11
|
* GET /api/boards/asks?ws&board&state → { ok, asks } state: open (default) | accepted | declined | all
|
|
12
|
-
* GET /api/boards/sprints?ws&board → { ok, source: jira|none, readAt, sprints }
|
|
12
|
+
* GET /api/boards/sprints?ws&board → { ok, source: jira|none, readAt, sprints } (each with goal, summary, themes, frozen)
|
|
13
13
|
* GET /api/boards/items?ws&board → { ok, build, jira, items, initiatives } PMOS tasks + Jira items (Build and deliver only)
|
|
14
14
|
* GET /api/boards/anchors?ws&board → { ok, anchors: [{ value, kind, group, label }], error } the declared KRs,
|
|
15
15
|
* health budgets and domains a new initiative can anchor to (D59)
|
|
@@ -548,7 +548,9 @@ const READS = {
|
|
|
548
548
|
} catch { /* no snapshot yet */ }
|
|
549
549
|
let whole = null;
|
|
550
550
|
try { whole = jiraStatus.readSprints(r.w.root); } catch { whole = null; }
|
|
551
|
-
|
|
551
|
+
let notes = null;
|
|
552
|
+
try { notes = jiraStatus.readSprintNotes(r.w.root); } catch { notes = null; }
|
|
553
|
+
ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, ...B.sprintsOf(snaps, (id) => ids.has(id), whole, notes) });
|
|
552
554
|
},
|
|
553
555
|
};
|
|
554
556
|
|
|
@@ -1000,7 +1002,9 @@ function meetingBoards(ctx) {
|
|
|
1000
1002
|
} catch { /* no snapshot yet */ }
|
|
1001
1003
|
let whole = null;
|
|
1002
1004
|
try { whole = jiraStatus.readSprints(w.root); } catch { whole = null; }
|
|
1003
|
-
|
|
1005
|
+
let notes = null;
|
|
1006
|
+
try { notes = jiraStatus.readSprintNotes(w.root); } catch { notes = null; }
|
|
1007
|
+
sprints = B.sprintsOf(snaps, (id) => ids.has(id), whole, notes);
|
|
1004
1008
|
}
|
|
1005
1009
|
return { ...head, health: B.health(rows), counts: { total: c.total, open: c.open, risk: c.risk, late: c.late },
|
|
1006
1010
|
rows: rows.filter((r) => r.status === 'late' || r.status === 'at risk').map((r) => ({ id: r.id, title: r.title, status: r.status, end: r.end || null, owner: r.owner || null })),
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.207.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.207.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -385,7 +385,17 @@ export function handle(S, method, path, p, body) {
|
|
|
385
385
|
const counts = { todo: issues.filter((i) => i.category === 'todo').length, doing: issues.filter((i) => i.category === 'doing').length, done: issues.filter((i) => i.category === 'done').length };
|
|
386
386
|
const num = key === 'IDPF' ? 42 : 19;
|
|
387
387
|
const extra = (k, state, name, cat, n) => { const is = Array.from({ length: n }, (_, i) => ({ key: key + '-' + (1100 + k * 20 + i), summary: (own[i % Math.max(1, own.length)] || { title: 'Sprint work' }).title + ': part ' + (i + 1), status: cat === 'done' ? 'Done' : 'To Do', category: cat, initiative: (own[i % Math.max(1, own.length)] || {}).id || null })); return { name, state, issues: is, counts: { todo: cat === 'todo' ? n : 0, doing: 0, done: cat === 'done' ? n : 0 } }; };
|
|
388
|
-
|
|
388
|
+
// The goal and summary the Jira status check writes (sprint-goal-summary). The goal is always written from the tickets;
|
|
389
|
+
// Jira's goal, when it has one, is a reference. The closed sprint was summarised once.
|
|
390
|
+
const ini0 = own[0] ? own[0].id : null;
|
|
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
|
+
{ title: 'Fixes and upkeep', line: 'Small fixes outside any initiative.', tickets: 2, done: 1, blocked: 0, people: 'Chen', initiative: null }];
|
|
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 };
|
|
395
|
+
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
|
+
const old = { ...extra(0, 'closed', key + ' Sprint ' + (num - 1), 'done', 5), goal: { from: 'tickets', text: 'Start the first initiative.', jira: null }, summary: 'Goal met. Five items shipped; two moved to the next sprint.', themes: [{ title: 'Sprint work', line: '', tickets: 5, done: 5, blocked: 0, people: '', initiative: ini0 }],
|
|
397
|
+
writtenAt: '2026-09-29T06:30:00Z', closed: { done: 5, total: 7, carried: 2, carriedTo: key + ' Sprint ' + num, points: '21 of 26' }, frozen: true };
|
|
398
|
+
return okb({ source: 'jira', readAt: TODAY + 'T08:10:00', sprints: [act, fut, old] });
|
|
389
399
|
}
|
|
390
400
|
return fail(404, 'No route ' + path);
|
|
391
401
|
}
|
|
@@ -1,18 +1,42 @@
|
|
|
1
|
-
/* pm/c3-ib-sprints.js — the Sprints tab of a board with Build and deliver on (mockup v5, 34-initiatives.js tabSprints
|
|
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
3
|
* registerBoardTab('sprints', (host, ctx) => ...)
|
|
3
|
-
* Reads GET /api/boards/sprints { source: jira|none, readAt, sprints: [{ name, state: active|future|closed, issues: [{ key, summary,
|
|
4
|
-
* category: todo|doing|done, initiative }], counts
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
4
|
+
* Reads GET /api/boards/sprints { source: jira|none, readAt, sprints: [{ name, state: active|future|closed, start, end, issues: [{ key, summary,
|
|
5
|
+
* status, category: todo|doing|done, initiative }], counts, goal: { from: 'tickets', text, jira } | null, jiraGoal?, summary,
|
|
6
|
+
* themes: [{ title, line, tickets, done, blocked, people, initiative }], writtenAt, closed: { done, total, carried, carriedTo, points } | null,
|
|
7
|
+
* frozen }] }. READ-ONLY: the sprints mirror Jira, so there is no create, edit or move control.
|
|
8
|
+
* Left: the active sprint's goal and summary (the goal, always written from the tickets, with Jira's goal as a reference; what the team is working on,
|
|
9
|
+
* one row per theme), its items (header chip: Jira, read-only and the read time; c3-board-declutter: no banner), then the next sprint
|
|
10
|
+
* with its goal and summary, and each closed sprint with its goal, summary, done and carried counts and the read-once note. Right: the
|
|
11
|
+
* active sprint's card, its burndown from GET /api/stage-tools/burndown (when that route is not on this server yet, the card says so
|
|
12
|
+
* quietly), and how sprints are read. source 'none' says there is no tracker. */
|
|
8
13
|
import { el } from './dom.js';
|
|
9
14
|
import { registerBoardTab } from './c3-initiatives.js';
|
|
10
|
-
import { icon, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols,
|
|
15
|
+
import { icon, lk, chip, tag, st, note, lbl, sect, card, list, empty, nrow, cols, kv, jiraLabel, day, put, plural } from './c3-ini-ui.js';
|
|
11
16
|
import { loadItems } from './c3-ini-work.js';
|
|
12
17
|
import { recordCards } from './c3-ini-records.js';
|
|
13
18
|
|
|
14
19
|
const CAT = { todo: 'To do', doing: 'In progress', done: 'Done' };
|
|
15
20
|
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
|
+
const timeOf = (s) => { const m = /T(\d{2}:\d{2})/.exec(String(s || '')); return m ? m[1] : ''; };
|
|
22
|
+
/* Quiet pages (c3-quiet.css) hide .ini-note, .ini-lhm .dim and a row's third chip. The goal's reason, the theme chips and the
|
|
23
|
+
* read-once note are the content of this tab (mockup v3), so they use their own classes. */
|
|
24
|
+
/* The goal is always written from the sprint's tickets: Jira's sprint goal is often empty, too broad or incomplete (the PM,
|
|
25
|
+
* 2026-10-14). Jira's own text shows underneath as a reference. */
|
|
26
|
+
const jiraRef = (j) => (j ? el('div', { class: 'dim ini-sp-note', 'data-inb': 'sprint-goal-jira' }, 'In Jira: “' + j + '”') : null);
|
|
27
|
+
function goalBlock(g, jiraGoal) {
|
|
28
|
+
if (!g) return el('div', { class: 'lh', 'data-inb': 'sprint-goal', 'data-inb-goal': 'missing' }, el('span', { class: 'ki' }, icon('target')),
|
|
29
|
+
el('div', { class: 'ini-sp-goal' }, lbl('SPRINT GOAL'), el('div', { class: 'dim ini-sp-note' }, 'No goal yet. The Jira status check writes one from the tickets at its next check.'), jiraRef(jiraGoal)));
|
|
30
|
+
return el('div', { class: 'lh', 'data-inb': 'sprint-goal', 'data-inb-goal': 'tickets' }, el('span', { class: 'ki' }, icon('target')),
|
|
31
|
+
el('div', { class: 'ini-sp-goal' }, lbl('SPRINT GOAL · from the tickets'), el('b', null, g.text), jiraRef(g.jira)));
|
|
32
|
+
}
|
|
33
|
+
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
|
+
title: t.title, attrs: { 'data-inb-theme': t.initiative || 'none' },
|
|
35
|
+
after: el('div', null, t.line ? el('div', { class: 'dim ini-sp-note' }, t.line) : null, el('div', { class: 'chips ini-sp-chips' }, ...[chip(plural(t.tickets, 'ticket')), t.done ? st('ok', 'check', t.done + ' done') : null, t.blocked ? st('warn', 'alert', t.blocked + ' blocked') : null,
|
|
36
|
+
t.people ? chip(icon('user', 'sm'), t.people) : null,
|
|
37
|
+
t.initiative ? lk({ onclick: () => ctx.openInitiative(t.initiative), 'data-inb-theme-ini': t.initiative }, icon('init', 'sm'), t.initiative) : el('span', { class: 'chip2 dim' }, 'No initiative')].filter(Boolean))) });
|
|
38
|
+
const summaryBody = (sp, ctx) => [goalBlock(sp.goal, sp.jiraGoal), sp.summary ? el('p', { class: 'ini-sp-sum' }, sp.summary) : null,
|
|
39
|
+
sp.themes && sp.themes.length ? [lbl('WHAT THE TEAM IS WORKING ON'), list(sp.themes.map((t) => themeRow(t, ctx)), { 'data-inb': 'sprint-themes' })] : null];
|
|
16
40
|
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') }; };
|
|
17
41
|
|
|
18
42
|
registerBoardTab('sprints', async (host, ctx) => {
|
|
@@ -31,21 +55,43 @@ registerBoardTab('sprints', async (host, ctx) => {
|
|
|
31
55
|
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')],
|
|
32
56
|
right: [el('span', { class: 'chip2', title: 'Status lives in Jira' }, icon('lock', 'sm'))], attrs: { 'data-inb-issue': i.key } });
|
|
33
57
|
const t = readTime(r.readAt);
|
|
58
|
+
const dates = (s) => (s.start && s.end ? day(s.start) + ' to ' + day(s.end) : '');
|
|
59
|
+
const itemsRow = (s) => { const c = countsOf(s); return nrow({ ic: 'clock', title: c.done + ' done, ' + c.doing + ' in progress, ' + c.todo + ' to do', xp: (s.issues || []).length ? [list(s.issues.map(issueRow))] : [note('No items.')] }); };
|
|
60
|
+
const closedSect = (s) => {
|
|
61
|
+
const c = countsOf(s); const x = s.closed || {}; const n = x.total || (s.issues || []).length; const done = x.total ? x.done : c.done;
|
|
62
|
+
return sect({ ic: 'check', title: s.name, count: n, right: tag('closed'), attrs: { 'data-inb-sprint': s.name, 'data-inb-state': 'closed' } },
|
|
63
|
+
...summaryBody({ ...s, themes: [] }, ctx),
|
|
64
|
+
el('div', { class: 'chips ini-sp-facts' }, dates(s) ? chip(icon('cal', 'sm'), dates(s)) : null, st('ok', 'check', done + ' of ' + n + ' done'),
|
|
65
|
+
x.points ? chip(x.points + ' points') : null, x.carried ? chip(icon('next', 'sm'), x.carried + ' carried' + (x.carriedTo ? ' to ' + x.carriedTo : '')) : null),
|
|
66
|
+
el('div', { class: 'dim ini-sp-note', 'data-inb': 'sprint-read-once' }, icon('lock', 'sm'), s.frozen ? ' Closed. Read and summarised once on ' + day(String(s.writtenAt || '').slice(0, 10)) + '. PMOS does not read a closed sprint again.'
|
|
67
|
+
: ' Closed. The Jira status check summarises it once at its next check, then does not read it again.'),
|
|
68
|
+
itemsRow(s));
|
|
69
|
+
};
|
|
70
|
+
const n = active ? (active.issues || []).length : 0;
|
|
34
71
|
const left = [
|
|
35
|
-
active ? sect({ ic: '
|
|
36
|
-
(active
|
|
72
|
+
active ? sect({ ic: 'clock', title: active.name + ' · goal and summary', right: st('ok', null, 'active'), attrs: { 'data-inb': 'sprint-summary' } },
|
|
73
|
+
...summaryBody(active, ctx),
|
|
74
|
+
el('div', { class: 'dim ini-sp-note', 'data-inb': 'sprint-written' }, active.summary ? 'Goal and summary written by the Jira status check' + (timeOf(active.writtenAt) ? ' at ' + timeOf(active.writtenAt) : '') + ' from all ' + plural(active.summarisedTickets != null ? active.summarisedTickets : n, 'ticket') + ' in the sprint. Read and summarised again at each check while the sprint is active.'
|
|
75
|
+
: 'No summary yet. The Jira status check writes one from the sprint\'s tickets at its next check.')) : null,
|
|
76
|
+
active ? sect({ ic: 'check', title: active.name + ' items', count: n, right: el('span', { class: 'chip2', 'data-inb': 'sprints-source', title: 'Mirrored from Jira ' + jiraLabel(b) + '. Every issue in the sprint, whoever it is assigned to. Change sprints in Jira.' }, icon('lock', 'sm'), 'Jira, read-only' + (t ? ' · read ' + t : '')), attrs: { 'data-inb-sprint': active.name, 'data-inb-state': 'active' } },
|
|
77
|
+
n ? list(active.issues.map(issueRow)) : empty('No items in this sprint yet.', { 'data-inb': 'sprint-no-issues' }))
|
|
37
78
|
: empty(sprints.length ? 'No sprint is active.' : 'Jira has no sprints for this board.', { 'data-inb': sprints.length ? 'sprints-no-active' : 'sprints-empty' }),
|
|
38
|
-
...others.
|
|
39
|
-
|
|
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 } },
|
|
80
|
+
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),
|
|
40
82
|
];
|
|
41
83
|
const right = [];
|
|
42
84
|
if (active) {
|
|
43
|
-
const c = countsOf(active);
|
|
44
|
-
right.push(card({ ic: 'clock', title: active.name, attrs: { 'data-inb': 'sprint-counts' } },
|
|
85
|
+
const c = countsOf(active);
|
|
86
|
+
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']])));
|
|
45
88
|
const ids = items.ok ? items.initiatives.map((i) => i.id) : [];
|
|
46
89
|
const pseudo = { ...ctx, row: { id: ids.join(','), title: '' } };
|
|
47
90
|
right.push(...await recordCards(pseudo, ['burndown']));
|
|
48
91
|
}
|
|
92
|
+
if (sprints.length) right.push(card({ ic: 'redo', title: 'How sprints are read', attrs: { 'data-inb': 'sprints-how' } }, kv([
|
|
93
|
+
['Goal', 'Written from the sprint\'s tickets. Jira\'s goal is shown for reference'], ['Active sprint', 'Read and summarised at each Jira status check'],
|
|
94
|
+
['Next sprint', 'Read at each check, until it starts'], ['Closed sprints', 'Read and summarised once, then kept'], ['Jira', 'Read-only. PMOS changes nothing there']])));
|
|
49
95
|
if (dead) return () => {};
|
|
50
96
|
put(host, cols(left, right));
|
|
51
97
|
return () => { dead = true; };
|
|
@@ -55,6 +55,11 @@
|
|
|
55
55
|
.c3 .ini-kv{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:6px 14px;margin:0;font-size:var(--fs-3)}
|
|
56
56
|
.c3 .ini-kv dt{color:var(--muted-foreground);font:var(--fs-2) var(--mono)}
|
|
57
57
|
.c3 .ini-kv dd{margin:0;min-width:0;overflow-wrap:anywhere}
|
|
58
|
+
.c3 .ini-sp-sum{margin:var(--sp-3) 0;line-height:1.5} /* a sprint's summary (sprint-goal-summary) */
|
|
59
|
+
.c3 .ini-sp-facts{margin:0 0 var(--sp-2)}
|
|
60
|
+
.c3 .ini-sp-goal{min-width:0}
|
|
61
|
+
.c3 .ini-sp-note{font-size:var(--fs-2);line-height:1.45;margin-top:var(--sp-1)}
|
|
62
|
+
.c3 .ini-sp-chips{margin-top:var(--sp-1)}
|
|
58
63
|
.c3 .ini-num{width:84px;flex:none}
|
|
59
64
|
.c3 .ini-up,.c3 .ini-down{display:inline-flex}
|
|
60
65
|
.c3 .ini-up{transform:rotate(-90deg)}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: jira-sync
|
|
3
3
|
label: "Jira status check"
|
|
4
|
-
description: "Read, read-only, the status, sprint and subtasks of the Jira tickets each initiative pushed, or of its Jira epics and the issues under them, and every issue in the sprints those tickets are in, and save them in PMOS for the initiative's Jira tickets tab and the board's Sprints tab. It writes nothing to Jira."
|
|
4
|
+
description: "Read, read-only, the status, sprint and subtasks of the Jira tickets each initiative pushed, or of its Jira epics and the issues under them, and every issue in the sprints those tickets are in, write each sprint's goal and a summary of the team's work, and save them in PMOS for the initiative's Jira tickets tab and the board's Sprints tab. It writes nothing to Jira."
|
|
5
5
|
run: agent
|
|
6
6
|
agent: claude
|
|
7
7
|
output:
|
|
@@ -14,7 +14,7 @@ output:
|
|
|
14
14
|
|
|
15
15
|
Rules that always hold:
|
|
16
16
|
1. READ-ONLY on Jira. Use only the Jira search tool. Never create, edit, transition, assign, comment on, link, close or delete an issue. Never write to Confluence or to any other source.
|
|
17
|
-
2. THE ONLY WRITE is `pmos jira sync`, and `pmos people record` once at the end (step 5). Never edit a repository file. Never run `pmos item`. Never write a ledger by hand. Never commit; the PM's Sync carries the files.
|
|
17
|
+
2. THE ONLY WRITE is `pmos jira sync`, `pmos jira summary` (step 4b), and `pmos people record` once at the end (step 5). Never edit a repository file. Never run `pmos item`. Never write a ledger by hand. Never commit; the PM's Sync carries the files.
|
|
18
18
|
3. LIGHT ON CONTENT. Ask Jira only for these fields: summary, status, the issue type, the parent, the sprint field, subtasks, the Flagged field, the story points field, the resolution date, and the display names of the assignee and the reporter. Never ask for or copy a description, a comment, an attachment, an email address, any other detail of a person, or a token.
|
|
19
19
|
4. TEMPORARY FILES OUTSIDE THE REPOSITORY. Save each search result in this session's scratch folder, never in the repository, and delete it after the sync.
|
|
20
20
|
5. NOTHING INVENTED. Give `pmos jira sync` exactly what Jira returned. If Jira cannot be reached, list the initiative under "Not reached" and write nothing for it.
|
|
@@ -27,7 +27,7 @@ Procedure:
|
|
|
27
27
|
- `type`: the issue type's name, for example `Epic`, `Story`, `Task` or `Bug`.
|
|
28
28
|
- `parent`: the parent issue's key (the epic of a story), or `null`.
|
|
29
29
|
- `category`: the status category, `new`, `indeterminate` or `done`.
|
|
30
|
-
- `sprint`: `{"id": "42", "name": "Sprint 42", "state": "active", "start": "2026-10-01", "end": "2026-10-14"}` for the active sprint, `"future"` for a planned one, or `null` when the issue is in the backlog. Copy `id`, `start`, `end
|
|
30
|
+
- `sprint`: `{"id": "42", "name": "Sprint 42", "state": "active", "start": "2026-10-01", "end": "2026-10-14"}` for the active sprint, `"future"` for a planned one, or `null` when the issue is in the backlog. Copy `id`, `start`, `end`, `completed` (a closed sprint's completion date) and `goal` (the sprint goal, as Jira writes it) from Jira's sprint field; leave out any that Jira does not give.
|
|
31
31
|
- `points`: the story points as a number, or `null` when none are set.
|
|
32
32
|
- `resolved`: the resolution date as `YYYY-MM-DD`, or `null` when the issue is not resolved.
|
|
33
33
|
- `subtasks`: `{"done": 2, "total": 5}`.
|
|
@@ -35,10 +35,16 @@ Procedure:
|
|
|
35
35
|
- `url`: the issue's browse link, `https://<site>.atlassian.net/browse/<KEY>`.
|
|
36
36
|
The shape Jira returned (`{"issues": [{"key", "self", "fields": {...}}]}`) is also accepted.
|
|
37
37
|
4. Run `pmos jira sync <initiative> --from <the file> --query "<the JQL>"`. Then delete the file.
|
|
38
|
-
4a. Read the whole sprints, after every initiative is synced. Run `pmos jira query --sprints`. It prints one Jira search for every issue in the sprints the tickets above are in, whoever the issue is assigned to. Do this even when the task names one initiative. If it prints "no Jira reading has a sprint yet", skip
|
|
38
|
+
4a. Read the whole sprints, after every initiative is synced. Run `pmos jira query --sprints`. It prints one Jira search for every issue in the sprints the tickets above are in, whoever the issue is assigned to. Do this even when the task names one initiative. If it prints "no Jira reading has a sprint yet" or "every sprint is closed and summarised once", skip to step 4b. Otherwise run that search with the same fields as step 2, fetch every page, save the result in the scratch folder in the same shape as step 3 (for example `<scratch>/jira-sprints.json`), run `pmos jira sync --sprints --from <the file> --query "<the JQL>"`, and delete the file. Do not record people from this search (step 5 is for the initiatives' own issues).
|
|
39
|
+
4b. Write each sprint's goal and summary, after step 4a. Run `pmos jira summary`. Each line is a sprint that needs one: its name, its state, its number of issues and Jira's goal. A closed sprint that is already summarised is listed as kept: do not write it again. If it prints "no sprint needs a summary", skip this step. For each listed sprint, read its issues in the sprint search result of step 4a and write:
|
|
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
|
+
- `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
|
+
- `themes`: one entry per group of related tickets, at most 8, largest first: `{"title", "line", "tickets", "done", "blocked", "people", "initiative"}`. Group by initiative when the tickets belong to one (the initiative id from step 1); put tickets with no initiative in one theme such as "Fixes and upkeep" with `"initiative": null`. `line` is one sentence; `people` is the assignees' first names, comma-separated.
|
|
43
|
+
- For a closed sprint, also `closed`: `{"done", "total", "carried", "carriedTo", "points"}` (`points` as "31 of 38" when the tickets have points).
|
|
44
|
+
Use only what the tickets say. Never invent work, a date or a person. Save `{"sprints": [{"name", "id", "state", "goal", "summary", "themes", "closed"}]}` in the scratch folder, run `pmos jira summary --from <the file>`, and delete the file.
|
|
39
45
|
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.
|
|
40
46
|
6. Finish with the three sections of the output contract, one short line each:
|
|
41
|
-
- 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,
|
|
47
|
+
- Checked: the initiative, the number of tickets read, and each change (status, sprint, blocked). If nothing changed, say "no change". Add how many people you recorded, how many issues the sprint read saved per sprint, and which sprints got a goal and summary (and whose goal you wrote from the tickets).
|
|
42
48
|
- Not reached: each initiative whose search failed, with the reason, and the sprint read if it failed.
|
|
43
49
|
- Needs you: tickets newly blocked, tickets the search did not return, and any refusal.
|
|
44
50
|
|