create-pmos 0.204.0 → 0.206.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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/payload/state/log.md +1 -1
  7. package/workspace/c3-capture-test.js +206 -0
  8. package/workspace/c3-ib-more-test.js +7 -1
  9. package/workspace/extension/README.md +22 -7
  10. package/workspace/extension/capture.js +114 -0
  11. package/workspace/extension/manifest.json +49 -8
  12. package/workspace/extension/popup.css +28 -0
  13. package/workspace/extension/popup.html +15 -0
  14. package/workspace/extension/popup.js +195 -0
  15. package/workspace/guide/jira.md +1 -0
  16. package/workspace/guide/report-design.md +15 -1
  17. package/workspace/lib/engine/boards.js +45 -2
  18. package/workspace/lib/extension-cli.js +6 -4
  19. package/workspace/lib/jira-cli.js +26 -3
  20. package/workspace/lib/jira-status.js +136 -6
  21. package/workspace/lib/report-design.js +62 -4
  22. package/workspace/lib/routes-boards.js +7 -3
  23. package/workspace/lib/routes-report-design.js +33 -0
  24. package/workspace/package-lock.json +2 -2
  25. package/workspace/package.json +1 -1
  26. package/workspace/public/pm/c3-boot.js +2 -1
  27. package/workspace/public/pm/c3-capture.css +23 -0
  28. package/workspace/public/pm/c3-capture.js +152 -0
  29. package/workspace/public/pm/c3-demo-boards.js +11 -1
  30. package/workspace/public/pm/c3-design-studio.js +17 -5
  31. package/workspace/public/pm/c3-ib-sprints.js +60 -13
  32. package/workspace/public/pm/c3-initiatives.css +5 -0
  33. package/workspace/public/pm/c3-shell.js +2 -0
  34. package/workspace/skills/jira-sync.skill +11 -5
  35. package/workspace/skills/report-design.skill +1 -0
  36. package/workspace/test-lists/report-design-studio-ui.json +3 -3
@@ -0,0 +1,195 @@
1
+ /* popup.js — the PMOS button's popup (popup.html): "Add this page to a report design".
2
+ * PMOS draws the popup: the page is one frame, ${address}/c3.html?capture=1&pop=1&…, which lists the reports and has the Add button. The extension never posts to PMOS
3
+ * itself, because every PMOS write needs a press on a PMOS page (a nonce and an Origin check). So when the frame asks (postMessage { pmosCapture: 'request' }), this
4
+ * page takes the open tab and posts it back to the frame ({ pmosCapture: 'data', … }), and the frame uploads it with its own press. Both ways, the message must
5
+ * come from the other window and from the PMOS origin; the extension posts to the PMOS origin only.
6
+ * Google Slides and Google Docs → the PDF, fetched here with the user's own Google cookies (host permission for https://docs.google.com/*)
7
+ * any other page → pictures of the whole page, scrolled top to bottom: at most 8, JPEG, 650 ms apart (Chrome's capture rate limit), scroll put back after
8
+ * When nothing answers at the address, this page says so and offers Try again. Nothing else is fetched. */
9
+ (function () {
10
+ 'use strict';
11
+ var A = window.PMOS_ADDRESS;
12
+ var C = window.PMOS_CAPTURE;
13
+ var CHECK_MS = 4000; // how long to wait for PMOS to answer
14
+ var READY_MS = 8000; // how long to wait for the frame to say it is drawn
15
+ var SETTLE_MS = 300; // after a scroll, before the picture
16
+ var msg = document.getElementById('msg');
17
+ var frame = null;
18
+ var frameOrigin = '';
19
+ var readyTimer = null;
20
+ var busy = false;
21
+ var current = null; // { tab, kind, address }
22
+
23
+ function readAddress(done) {
24
+ var fallback = A.DEFAULT_ADDRESS;
25
+ try {
26
+ var o = {}; o[A.KEY] = fallback;
27
+ chrome.storage.sync.get(o, function (v) { done(A.normalizeAddress(v && v[A.KEY]) || fallback); });
28
+ } catch (e) { done(fallback); }
29
+ }
30
+ function getTab(done) {
31
+ try { chrome.tabs.query({ active: true, currentWindow: true }, function (t) { done((t && t[0]) || null); }); } catch (e) { done(null); }
32
+ }
33
+ /** Resolves when something answers at the address (an opaque no-cors reply is enough), rejects when nothing listens. */
34
+ function answers(addr) {
35
+ var ctl = typeof AbortController === 'function' ? new AbortController() : null;
36
+ var t = ctl ? setTimeout(function () { ctl.abort(); }, CHECK_MS) : null;
37
+ return fetch(addr + '/c3.html', { mode: 'no-cors', cache: 'no-store', signal: ctl ? ctl.signal : undefined })
38
+ .then(function (r) { if (t) clearTimeout(t); return r; }, function (e) { if (t) clearTimeout(t); throw e; });
39
+ }
40
+ var sleep = function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); };
41
+
42
+ /* ── the popup's own states ── */
43
+ function head(host) {
44
+ var h = document.createElement('div'); h.className = 'head';
45
+ var b = document.createElement('b'); b.textContent = 'PMOS'; h.appendChild(b);
46
+ var s = document.createElement('span'); s.textContent = 'Add this page to a report design'; h.appendChild(s);
47
+ host.appendChild(h);
48
+ }
49
+ function show(text, withRetry, extra) {
50
+ if (frame) { frame.remove(); frame = null; }
51
+ clearTimeout(readyTimer);
52
+ msg.textContent = '';
53
+ msg.className = 'msg';
54
+ if (!msg.parentNode) document.body.appendChild(msg);
55
+ head(msg);
56
+ var p = document.createElement('p'); p.textContent = text; msg.appendChild(p);
57
+ if (extra) msg.appendChild(extra);
58
+ if (withRetry) {
59
+ var b = document.createElement('button'); b.type = 'button'; b.textContent = 'Try again'; b.setAttribute('data-act', 'retry');
60
+ b.addEventListener('click', function () { start(); });
61
+ msg.appendChild(b);
62
+ }
63
+ }
64
+ function showDown(addr) {
65
+ var a = document.createElement('a'); a.href = '#'; a.textContent = 'Set the address';
66
+ a.addEventListener('click', function (e) { e.preventDefault(); try { chrome.runtime.openOptionsPage(); } catch (x) { /* no options page */ } });
67
+ show('PMOS is not answering at ' + addr + '. Start PMOS, then try again.', true, a);
68
+ }
69
+
70
+ /* ── the frame ── */
71
+ function post(m) {
72
+ if (!frame || !frame.contentWindow || !C.validOutgoing(m)) return false;
73
+ frame.contentWindow.postMessage(m, frameOrigin);
74
+ return true;
75
+ }
76
+ function showFrame(tab, kind, addr, screens) {
77
+ var q = new URLSearchParams({ capture: '1', pop: '1', pkind: kind, ptitle: C.sampleName(tab.title, tab.url), purl: tab.url || '' });
78
+ if (screens) q.set('pscreens', String(screens));
79
+ frame = document.createElement('iframe');
80
+ frame.title = 'PMOS: add this page to a report design';
81
+ frame.src = addr + '/c3.html?' + q.toString();
82
+ frameOrigin = new URL(addr).origin;
83
+ if (msg.parentNode) msg.remove();
84
+ document.body.appendChild(frame);
85
+ // an older PMOS answers but has no capture page: it never says ready
86
+ readyTimer = setTimeout(function () { showDown(addr); }, READY_MS);
87
+ }
88
+
89
+ /* ── what is taken ── */
90
+ var toBase64 = function (buf) {
91
+ var bytes = new Uint8Array(buf); var s = '';
92
+ for (var i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
93
+ return btoa(s);
94
+ };
95
+ var run = function (tabId, func, args) {
96
+ return chrome.scripting.executeScript({ target: { tabId: tabId }, func: func, args: args || [] }).then(function (r) { return r && r[0] ? r[0].result : undefined; });
97
+ };
98
+ var measure = function () { var d = document.documentElement; var b = document.body; return { h: Math.max(d ? d.scrollHeight : 0, b ? b.scrollHeight : 0), vh: window.innerHeight, y: window.scrollY }; };
99
+ var scrollTo = function (y) { window.scrollTo({ top: y, left: 0, behavior: 'instant' }); return window.scrollY; };
100
+
101
+ function takePdf(tab, kind) {
102
+ var url = C.exportUrl(tab.url);
103
+ post({ pmosCapture: 'progress', text: 'Getting the PDF from Google', step: 1, of: 3 });
104
+ return fetch(url, { credentials: 'include', cache: 'no-store', redirect: 'follow' }).then(function (r) {
105
+ if (!r.ok) throw new Error('Google did not give the PDF (' + r.status + '). Open the file in Chrome while signed in to Google, then try again.');
106
+ return r.arrayBuffer();
107
+ }, function () { throw new Error('Chrome could not reach Google to get the PDF. Check the connection, then try again.'); }).then(function (buf) {
108
+ var bytes = new Uint8Array(buf);
109
+ if (!(bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46)) throw new Error('Google did not give a PDF. Sign in to Google in this Chrome, make sure you can open the file, then try again.');
110
+ if (bytes.length > C.MAX_BYTES) throw new Error('The PDF is over 20 MB. PMOS keeps a sample up to 20 MB. Add a smaller copy.');
111
+ post({ pmosCapture: 'progress', text: 'Reading the PDF', step: 2, of: 3 });
112
+ var pages = C.pdfPageCount(bytes);
113
+ return { pmosCapture: 'data', kind: 'pdf', name: C.sampleName(tab.title, tab.url), url: tab.url, pages: pages || undefined, data: toBase64(buf) };
114
+ });
115
+ }
116
+
117
+ function takePictures(tab) {
118
+ var tabId = tab.id; var windowId = tab.windowId;
119
+ var start = null; var pics = []; var bytes = 0; var last = 0;
120
+ return run(tabId, measure).then(function (m) {
121
+ if (!m || !m.vh) throw new Error('Chrome does not let PMOS read this page.');
122
+ start = m.y || 0;
123
+ var plan = C.scrollPlan(m.h, m.vh, C.MAX_PICTURES);
124
+ var n = plan.offsets.length;
125
+ var chain = Promise.resolve();
126
+ plan.offsets.forEach(function (y, i) {
127
+ chain = chain.then(function () {
128
+ post({ pmosCapture: 'progress', text: 'Picture ' + (i + 1) + ' of ' + n + (i ? ', scrolling down' : ''), step: i + 1, of: n + 1 });
129
+ return run(tabId, scrollTo, [y]);
130
+ }).then(function () {
131
+ var wait = Math.max(SETTLE_MS, C.MIN_GAP_MS - (Date.now() - last));
132
+ return sleep(wait);
133
+ }).then(function () {
134
+ last = Date.now();
135
+ return chrome.tabs.captureVisibleTab(windowId, { format: 'jpeg', quality: 80 });
136
+ }).then(function (dataUrl) {
137
+ var b64 = String(dataUrl || '').replace(/^data:[^,]*,/, '');
138
+ if (!b64) throw new Error('Chrome did not give a picture of the page.');
139
+ bytes += C.base64Bytes(b64);
140
+ if (bytes > C.MAX_BYTES) throw new Error('The pictures are over 20 MB. PMOS keeps a sample up to 20 MB. Open a shorter page, or add it as a PDF.');
141
+ pics.push(b64);
142
+ });
143
+ });
144
+ return chain;
145
+ }).then(function () {
146
+ return { pmosCapture: 'data', kind: 'pictures', name: C.sampleName(tab.title, tab.url), url: tab.url, pictures: pics };
147
+ }).then(function (out) { return restore(tabId, start).then(function () { return out; }); },
148
+ function (e) { return restore(tabId, start).then(function () { throw e; }); });
149
+ }
150
+ function restore(tabId, y) { return y == null ? Promise.resolve() : run(tabId, scrollTo, [y]).catch(function () { /* the page went away */ }); }
151
+
152
+ function capture() {
153
+ if (busy || !current) return;
154
+ busy = true;
155
+ var tab = current.tab;
156
+ var job = C.exportUrl(tab.url) ? takePdf(tab, current.kind) : takePictures(tab);
157
+ job.then(function (out) {
158
+ post({ pmosCapture: 'progress', text: 'Sending it to PMOS', step: out.kind === 'pdf' ? 3 : (out.pictures.length + 1), of: out.kind === 'pdf' ? 3 : (out.pictures.length + 1) });
159
+ if (!post(out)) throw new Error('The page could not be sent to PMOS.');
160
+ }).catch(function (e) {
161
+ post({ pmosCapture: 'error', error: String((e && e.message) || 'The page could not be taken.') });
162
+ }).then(function () { busy = false; });
163
+ }
164
+
165
+ window.addEventListener('message', function (e) {
166
+ var m = C.fromFrame(e, frame && frame.contentWindow, frameOrigin);
167
+ if (!m) return;
168
+ if (m.pmosCapture === 'ready') { clearTimeout(readyTimer); return; }
169
+ if (m.pmosCapture === 'resize') { if (frame) frame.style.height = m.height + 'px'; return; }
170
+ if (m.pmosCapture === 'close') { window.close(); return; }
171
+ if (m.pmosCapture === 'open') {
172
+ try { chrome.tabs.create({ url: frameOrigin + '/c3.html?studio=' + encodeURIComponent(m.report) }); } catch (x) { /* no tabs */ }
173
+ window.close();
174
+ return;
175
+ }
176
+ if (m.pmosCapture === 'request') capture();
177
+ });
178
+
179
+ function start() {
180
+ busy = false;
181
+ readAddress(function (addr) {
182
+ getTab(function (tab) {
183
+ var kind = tab ? C.classifyUrl(tab.url) : 'blocked';
184
+ if (!tab || kind === 'blocked') { show('Chrome does not let PMOS read this page. Open a web page, such as a Google Slides deck or any site, then press the PMOS button.', false); return; }
185
+ current = { tab: tab, kind: kind, address: addr };
186
+ var go = function (screens) {
187
+ answers(addr).then(function () { showFrame(tab, C.pageKind(tab.url), addr, screens); }, function () { showDown(addr); });
188
+ };
189
+ if (kind !== 'other') { go(0); return; }
190
+ run(tab.id, measure).then(function (m) { go(m && m.vh ? C.scrollPlan(m.h, m.vh, 1000).total : 0); }, function () { go(0); });
191
+ });
192
+ });
193
+ }
194
+ start();
195
+ })();
@@ -163,6 +163,7 @@ The morning brief, the day check and requirement gathering use it to tie a Jira
163
163
  - `pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run]`
164
164
  - `pmos jira query --sprints`
165
165
  - `pmos jira sync --sprints --from <file|-> [--query "<jql>"] [--dry-run]`
166
+ - `pmos jira summary [--from <file|->] [--dry-run]`
166
167
  - `pmos jira which <KEY>`
167
168
 
168
169
  Every command takes `--json` and `--root <dir>`.
@@ -22,7 +22,7 @@ Use one of these two buttons:
22
22
 
23
23
  1. Select **Deck** or **Document** at the top of the studio.
24
24
  2. Write what the report is for and who reads it. For example: "This goes to the progress session every other Thursday. The decisions must stand out."
25
- 3. Press **Add a sample** to give PMOS a report that people liked. You can upload a file, paste a link, or pick a report document that PMOS has. Each sample shows what PMOS took from it. PMOS reads a link from Google Slides, Docs or Sheets, or from Confluence, through your own connectors. It saves the words and the structure (slide titles, order, sections and tables), and it changes nothing in the file. It does not get the look. To match the look, also add the file as a PDF or a screenshot. For Google Slides, choose **File**, **Download**, **PDF document (.pdf)** in Slides, then add that file. If your connectors cannot read the link, the sample says so. Any other link is kept by its address, and PMOS cannot open it. PMOS sees the pages of a PDF or an image, so it can follow their look. From a .pptx or a .docx, PMOS gets only the words.
25
+ 3. Press **Add a sample** to give PMOS a report that people liked. You can upload a file, paste a link, add a page from Chrome (see below), or pick a report document that PMOS has. Each sample shows what PMOS took from it. PMOS reads a link from Google Slides, Docs or Sheets, or from Confluence, through your own connectors. It saves the words and the structure (slide titles, order, sections and tables), and it changes nothing in the file. It does not get the look. To match the look, also add the file as a PDF or a screenshot. For Google Slides, choose **File**, **Download**, **PDF document (.pdf)** in Slides, then add that file. If your connectors cannot read the link, the sample says so. Any other link is kept by its address, and PMOS cannot open it. PMOS sees the pages of a PDF or an image, so it can follow their look. From a .pptx or a .docx, PMOS gets only the words.
26
26
  4. Press **Make the next version**. PMOS makes a version from your words and this cycle's data. This takes a short time. You can leave the studio. The studio shows when the version is ready.
27
27
  5. Read the version in the preview. Write what to change and press **Make the next version** again. Each turn makes a new version: D1, D2, D3 and so on.
28
28
 
@@ -30,6 +30,20 @@ To set the width of the studio, use **Half**, **3/4** or **Whole** at the top ri
30
30
 
31
31
  If a turn fails, the studio shows why. Nothing changes. Try the turn again.
32
32
 
33
+
34
+ ### Add a page from Chrome
35
+
36
+ You can add the page you are looking at in Chrome, on any website, as a sample. This needs the PMOS extension in Chrome. Run `pmos extension` in a terminal for the steps to load it.
37
+
38
+ 1. In Chrome, open the page, for example a Google Slides deck, a Google Doc or a Confluence page.
39
+ 2. Press the **PMOS** button in the Chrome toolbar. The popup says "Add this page to a report design".
40
+ 3. Choose the report in the **Report** list. It starts on the report whose design studio you opened last.
41
+ 4. Press **Add as a sample**. Keep the popup open while it saves. When it says "Added to" and the name of the report, press **Open the studio**.
42
+
43
+ A Google Slides deck or a Google Doc comes to PMOS as a PDF, so PMOS can follow its look. The sample reads "From Chrome, PDF, 12 slides", or "pages" for a document. Chrome asks Google for the PDF with your own Google sign-in. Any other page comes as pictures of the whole page, scrolled top to bottom, at most 8 pictures. The sample reads "From Chrome, 3 page pictures". PMOS reads every picture, and follows its look as it does for a PDF. A sample is at most 20 MB. If the page is bigger, the popup says so: open a shorter page, or add a smaller copy.
44
+
45
+ The sample shows in a design studio that is already open, in a few seconds. If the popup says "PMOS is not answering", start PMOS and press **Try again**. Nothing leaves your computer: the popup sends the page only to your own PMOS.
46
+
33
47
  ### Point at a part
34
48
 
35
49
  Keep **Point and say** on. Click a part of the preview and write what to change there. A numbered marker shows the spot. Your next turn sends the markers with your words.
@@ -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,49 @@ 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: n.goal.from, text: n.goal.text, jira: n.goal.from === 'jira' ? n.goal.text : n.goal.jira || null };
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 = jg ? { from: 'jira', text: jg, jira: jg, unchecked: true } : null;
668
+ sp.summary = null; sp.themes = []; sp.writtenAt = null; sp.summarisedTickets = null; sp.closed = null; sp.frozen = false;
669
+ }
670
+ }
628
671
  const order = { active: 0, future: 1, closed: 2 };
629
672
  const sprints = [...byName.values()].sort((a, b) => ((order[a.state] ?? 3) - (order[b.state] ?? 3)) || a.name.localeCompare(b.name));
630
673
  for (const sp of sprints) sp.issues.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }));
@@ -1,5 +1,5 @@
1
1
  'use strict';
2
- /* extension-cli.js — `pmos extension`: where the Chrome extension for Google Meet is, and how to load it (initiative c3-meet-extension).
2
+ /* extension-cli.js — `pmos extension`: where the PMOS Chrome extension is (the Meet panel and the button that adds a page to a report design), and how to load it (initiatives c3-meet-extension, report-design-studio).
3
3
  *
4
4
  * pmos extension print the absolute path of workspace/extension and the steps to load it in Chrome
5
5
  *
@@ -10,20 +10,22 @@ const path = require('path');
10
10
 
11
11
  const COMMANDS = { extension: [] };
12
12
  const FOLDER = path.resolve(__dirname, '..', 'extension');
13
- const USAGE = ['usage: pmos extension — the Chrome extension that shows the meeting companion over a Google Meet call'];
13
+ const USAGE = ['usage: pmos extension — the Chrome extension: the meeting companion over a Google Meet call, and a button that adds a page to a report design'];
14
14
 
15
15
  /** The lines `pmos extension` prints for a folder. Pure. */
16
16
  function lines(folder, exists = true) {
17
17
  const out = [];
18
18
  if (!exists) { out.push(`✗ the extension folder is not here: ${folder}`, ' Reinstall the workspace with `pmos upgrade`.'); return out; }
19
- out.push('PMOS for Google Meet (Chrome)', '', ` folder ${folder}`, '',
19
+ out.push('PMOS for Chrome', '', ` folder ${folder}`, '',
20
20
  'Load it in three steps:',
21
21
  ' 1. In Chrome, open chrome://extensions',
22
22
  ' 2. Turn on Developer mode (top right)',
23
23
  ' 3. Press Load unpacked, then pick the folder above',
24
24
  '',
25
25
  'PMOS is looked for at http://localhost:7433. If pmos printed another port, open the extension\'s Options and set that address.',
26
- 'Then open a Google Meet call: the panel shows at the bottom right.');
26
+ 'Then open a Google Meet call: the panel shows at the bottom right.',
27
+ 'To add a page to a report design, open any page and press the PMOS button in the toolbar. Choose the report, then Add as a sample.',
28
+ 'After an update, press the reload arrow on the extension\'s card in chrome://extensions.');
27
29
  return out;
28
30
  }
29
31
 
@@ -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,122 @@ 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: Jira's goal when it says what the sprint delivers, else a goal
340
+ // written from the tickets, with why (none: Jira has none; broad: too broad; partial: leaves out part of the sprint). A closed
341
+ // sprint's note is written once and kept; its sprint is not searched again.
342
+ const NOTES_FILE = '_sprint-notes';
343
+ const GOAL_FROM = ['jira', 'none', 'broad', 'partial'];
344
+ const INI_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$/;
345
+ const sameSprint = (a, b) => !!a && !!b && ((a.id && b.id) ? String(a.id) === String(b.id) : String(a.name) === String(b.name));
346
+ function readSprintNotes(root) {
347
+ let text;
348
+ try { text = fs.readFileSync(snapshotPath(root, NOTES_FILE), 'utf8'); } catch { return null; }
349
+ const m = /```json\n([\s\S]*?)\n```/.exec(text);
350
+ if (!m) return null;
351
+ let data;
352
+ try { data = JSON.parse(m[1]); } catch { return null; }
353
+ const notes = (Array.isArray(data.sprints) ? data.sprints : []).filter((x) => x && isStr(x.name) && x.goal && isStr(x.summary));
354
+ return { writtenAt: isStr(data.written_at) ? data.written_at : null, notes };
355
+ }
356
+ function frozenNotes(root) { const r = readSprintNotes(root); return r ? r.notes.filter((x) => x.state === 'closed') : []; }
357
+ const count = (v, max) => (Number.isInteger(v) && v >= 0 && v <= (max == null ? 100000 : max) ? v : null);
358
+ const text = (v, n) => clip(oneLine(v), n);
359
+ /** One sprint's note from the agent → { note } | { error }. Nothing is guessed: a missing required field refuses the note. */
360
+ function noteOne(x) {
361
+ if (!x || typeof x !== 'object') return { error: 'a sprint note is not an object' };
362
+ const name = text(x.name, 80);
363
+ if (!name) return { error: 'a sprint note has no name' };
364
+ const state = String(x.state || '').toLowerCase();
365
+ if (!SPRINT_STATES.includes(state)) return { error: `${name}: state is active, future or closed` };
366
+ const g = x.goal && typeof x.goal === 'object' ? x.goal : null;
367
+ if (!g || !GOAL_FROM.includes(g.from)) return { error: `${name}: goal.from is jira, none, broad or partial` };
368
+ const gt = text(g.text, 240);
369
+ if (!gt) return { error: `${name}: the goal has no text` };
370
+ const goal = { from: g.from, text: gt };
371
+ if (g.from === 'broad' || g.from === 'partial') {
372
+ const j = text(g.jira, 300);
373
+ if (!j) return { error: `${name}: a ${g.from} goal carries Jira's own goal in goal.jira` };
374
+ goal.jira = j;
375
+ }
376
+ const summary = text(x.summary, 900);
377
+ if (!summary) return { error: `${name}: the summary is empty` };
378
+ const themes = [];
379
+ for (const t of Array.isArray(x.themes) ? x.themes.slice(0, 8) : []) {
380
+ const title = t && text(t.title, 80);
381
+ if (!title) return { error: `${name}: a theme has no title` };
382
+ const tickets = count(t.tickets);
383
+ if (tickets == null) return { error: `${name}: theme "${title}" needs a ticket count` };
384
+ const ini = t.initiative == null || t.initiative === '' ? null : String(t.initiative).trim();
385
+ if (ini != null && !INI_RE.test(ini)) return { error: `${name}: theme "${title}" has a bad initiative id` };
386
+ 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 });
387
+ }
388
+ const note = { name, state, goal, summary, themes };
389
+ const id = isStr(x.id) || Number.isInteger(x.id) ? String(x.id) : '';
390
+ if (/^\d{1,12}$/.test(id)) note.id = id;
391
+ if (state === 'closed' && x.closed && typeof x.closed === 'object') {
392
+ const c = x.closed;
393
+ 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 };
394
+ }
395
+ return { note };
396
+ }
397
+ /** The sprints of the whole-sprint read that need a note: every active and future one, and each closed one not yet noted. */
398
+ function sprintsToSummarise(root) {
399
+ const whole = readSprints(root);
400
+ const frozen = frozenNotes(root);
401
+ const by = new Map();
402
+ for (const t of whole ? whole.tickets.values() : []) {
403
+ const sp = t.sprint;
404
+ if (!sp || !sp.name) continue;
405
+ if (!by.has(sp.name)) by.set(sp.name, { name: sp.name, ...(sp.id ? { id: sp.id } : {}), state: sp.state, tickets: 0, jiraGoal: null });
406
+ const o = by.get(sp.name);
407
+ o.tickets++;
408
+ if (!o.jiraGoal && sp.goal) o.jiraGoal = sp.goal;
409
+ }
410
+ const all = [...by.values()].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
411
+ const isKept = (s) => frozen.some((f) => sameSprint(f, s)); // the note was written when Jira said closed
412
+ return { ok: true, read: !!whole, sprints: all.filter((s) => !isKept(s)), kept: all.filter(isKept).map((s) => s.name) };
413
+ }
414
+ /** Save the notes the agent wrote. A closed sprint already noted keeps its first note. A bad note refuses the whole file. */
415
+ function saveSprintNotes(root, raw, { dryRun = false, now } = {}) {
416
+ const list = Array.isArray(raw) ? raw : raw && Array.isArray(raw.sprints) ? raw.sprints : null;
417
+ if (!list) return { ok: false, error: 'the file holds no "sprints": [...] list' };
418
+ const got = [];
419
+ for (const x of list) { const r = noteOne(x); if (r.error) return { ok: false, error: r.error }; got.push(r.note); }
420
+ const prev = readSprintNotes(root);
421
+ const notes = prev ? prev.notes.slice() : [];
422
+ const whole = readSprints(root);
423
+ const wt = whole ? [...whole.tickets.values()] : [];
424
+ const writtenAt = nowIso(now);
425
+ const saved = []; const kept = [];
426
+ for (const n of got) {
427
+ const i = notes.findIndex((o) => sameSprint(o, n));
428
+ if (i >= 0 && notes[i].state === 'closed') { kept.push(n.name); continue; }
429
+ const mine = wt.filter((t) => t.sprint && sameSprint(t.sprint, n));
430
+ const sp = mine.length ? mine[0].sprint : {};
431
+ const out = { ...n, writtenAt, tickets: mine.length, ...(sp.start ? { start: sp.start } : {}), ...(sp.end ? { end: sp.end } : {}), ...(sp.completed ? { completed: sp.completed } : {}) };
432
+ if (!out.id && sp.id) out.id = sp.id;
433
+ if (i >= 0) notes[i] = out; else notes.push(out);
434
+ saved.push(n.name);
435
+ }
436
+ const result = { ok: true, saved, kept, file: snapshotRel(root, NOTES_FILE), dryRun: !!dryRun };
437
+ if (dryRun || !saved.length) return result;
438
+ const file = snapshotPath(root, NOTES_FILE);
439
+ fs.mkdirSync(path.dirname(file), { recursive: true });
440
+ const rows = notes.map((o) => `| ${cell(o.name)} | ${cell(o.state)} | ${cell(o.goal.text)} | ${o.goal.from === 'jira' ? 'Jira' : 'written from the tickets'} | ${cell(o.writtenAt)} |`);
441
+ const body = [
442
+ '---', 'type: Reference', 'title: "Jira sprints: goal and summary"', `timestamp: ${writtenAt}`, '---', '',
443
+ '# Jira sprints: goal and summary', '',
444
+ '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.', '',
445
+ '| Sprint | State | Goal | Goal from | Written |', '|---|---|---|---|---|', ...rows, '',
446
+ '```json', JSON.stringify({ written_at: writtenAt, sprints: notes }, null, 0), '```', '',
447
+ ].join('\n');
448
+ const tmp = `${file}.${process.pid}.tmp`;
449
+ fs.writeFileSync(tmp, body);
450
+ fs.renameSync(tmp, file);
451
+ return { ...result, writtenAt };
452
+ }
453
+
325
454
  // ── J13 · which initiative ─────────────────────────────────────────────────────────────────────────
326
455
  function which(root, key) {
327
456
  const k = String(key || '').trim().toUpperCase();
@@ -335,4 +464,5 @@ function which(root, key) {
335
464
  return { ok: true, key: k, initiatives: hits };
336
465
  }
337
466
 
338
- module.exports = { queryFor, linkedInitiatives, normalize, sync, readSnapshot, which, sprintLabel, snapshotPath, snapshotRel, sprintQuery, syncSprints, readSprints, SPRINTS_FILE };
467
+ module.exports = { queryFor, linkedInitiatives, normalize, sync, readSnapshot, which, sprintLabel, snapshotPath, snapshotRel, sprintQuery, syncSprints, readSprints, SPRINTS_FILE,
468
+ sprintsToSummarise, saveSprintNotes, readSprintNotes, sameSprint, NOTES_FILE };