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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.205.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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@3947135
2
- assembled: 2026-10-13
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-13T13:16:48Z"}
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-13T13:16:48Z"}
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-13T13:16:48Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T01:10:19Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  Newest first, ISO dates.
4
4
 
5
- ## 2026-10-13
5
+ ## 2026-10-14
6
6
 
7
7
  - Adopted OKF v0.2 (D73).
@@ -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]) + Number(m[2]) + Number(m[3]) === 12)(/Done(\d+)Inprogress(\d+)Todo(\d+)/.exec(document.querySelector('[data-inb="sprint-counts"]').textContent.replace(/\s+/g, '')))));
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 ── */
@@ -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-13
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?) → { source, readAt, sprints }
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 seen = new Map();
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
- ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, ...B.sprintsOf(snaps, (id) => ids.has(id), whole) });
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
- sprints = B.sprintsOf(snaps, (id) => ids.has(id), whole);
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.205.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.205.0",
9
+ "version": "0.207.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.205.0",
3
+ "version": "0.207.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -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
- return okb({ source: 'jira', readAt: TODAY + 'T08:10:00', sprints: [{ name: key + ' Sprint ' + num, state: 'active', issues, counts }, extra(1, 'future', key + ' Sprint ' + (num + 1), 'todo', 3), extra(0, 'closed', key + ' Sprint ' + (num - 1), 'done', 5)] });
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, status,
4
- * category: todo|doing|done, initiative }], counts }] }. READ-ONLY: the sprints mirror Jira, so there is no create, edit or move control.
5
- * Left: the active sprint's items, its header chip saying Jira, read-only and the read time (c3-board-declutter: no banner), as rows (a linked initiative opens its page), then the
6
- * future and closed sprints in place. Right: the active sprint's bars, and its burndown from GET /api/stage-tools/burndown (built by
7
- * the stage-records change; when that route is not on this server yet, the card says so quietly). source 'none' says there is no tracker. */
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, hbs, hbr, jiraLabel, day, put } from './c3-ini-ui.js';
15
+ import { icon, lk, chip, tag, st, note, lbl, sect, card, list, empty, nrow, cols, kv, jiraLabel, day, put, plural } from './c3-ini-ui.js';
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: 'check', title: active.name + ' items', count: (active.issues || []).length, right: el('span', { class: 'chip2', 'data-inb': 'sprints-source', title: 'Mirrored from Jira ' + jiraLabel(b) + '. Change sprints in Jira.' }, icon('lock', 'sm'), 'Jira, read-only' + (t ? ' · read ' + t : '')), attrs: { 'data-inb-sprint': active.name, 'data-inb-state': 'active' } },
36
- (active.issues || []).length ? list(active.issues.map(issueRow)) : empty('No items in this sprint yet.', { 'data-inb': 'sprint-no-issues' }))
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.map((s) => { const c = countsOf(s); return sect({ ic: s.state === 'closed' ? 'check' : 'clock', title: s.name, count: (s.issues || []).length, right: tag(s.state || 'sprint'), attrs: { 'data-inb-sprint': s.name, 'data-inb-state': s.state } },
39
- 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.')] })); }),
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); const tot = Math.max(1, c.todo + c.doing + c.done);
44
- right.push(card({ ic: 'clock', title: active.name, attrs: { 'data-inb': 'sprint-counts' } }, hbs([hbr('Done', c.done, c.done / tot, 'ok'), hbr('In progress', c.doing, c.doing / tot, 'acc'), hbr('To do', c.todo, c.todo / tot, 'pm')])));
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` and `completed` (a closed sprint's completion date) from Jira's sprint field; leave out any that Jira does not give.
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 this step. 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).
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, and how many issues the sprint read saved per sprint.
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