create-pmos 0.213.0 → 0.215.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 (35) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  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/workspace/c3-ib-more-test.js +23 -4
  7. package/workspace/c3-sessions-panel-test.js +69 -11
  8. package/workspace/c3-work-test.js +2 -2
  9. package/workspace/guide/console3-sessions.md +33 -9
  10. package/workspace/guide/jira.md +13 -1
  11. package/workspace/guide/my-work.md +4 -1
  12. package/workspace/lib/day-run.js +30 -1
  13. package/workspace/lib/engine/boards.js +51 -7
  14. package/workspace/lib/jira-status.js +46 -1
  15. package/workspace/lib/my-work.js +1 -1
  16. package/workspace/lib/proactive-cli.js +8 -2
  17. package/workspace/lib/routes-my-work.js +44 -17
  18. package/workspace/lib/routes-pm-items.js +8 -0
  19. package/workspace/lib/transcript-read.js +93 -10
  20. package/workspace/lib/work-assigned.js +23 -11
  21. package/workspace/package-lock.json +2 -2
  22. package/workspace/package.json +1 -1
  23. package/workspace/public/pm/c3-demo-boards.js +12 -3
  24. package/workspace/public/pm/c3-ib-sprints.js +142 -39
  25. package/workspace/public/pm/c3-initiatives.css +18 -0
  26. package/workspace/public/pm/c3-session-conv.js +164 -0
  27. package/workspace/public/pm/c3-session-term.js +63 -16
  28. package/workspace/public/pm/c3-settings.js +1 -1
  29. package/workspace/public/pm/console3.css +62 -0
  30. package/workspace/public/pm/pm-work-view.js +37 -11
  31. package/workspace/public/pm/tab-term.js +9 -0
  32. package/workspace/server.js +32 -12
  33. package/workspace/skills/day-task.skill +1 -0
  34. package/workspace/skills/jira-sync.skill +9 -3
  35. package/workspace/test-files.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.213.0",
3
+ "version": "0.215.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@4425c43
1
+ kernel-source: pmos@8822e45
2
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-14T03:05:22Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T03:35:59Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-14T03:05:22Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T03:35:59Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-14T03:05:22Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T03:35:59Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -43,13 +43,32 @@ const { harness } = require('./c3-ini-harness');
43
43
  await open('board:idpf:sprints');
44
44
  await until(() => st(() => !!document.querySelector('[data-inb="sprints-source"]')));
45
45
  ok('SP1 the active sprint header says Jira, read-only, and there is no banner', /Jira, read-only/.test(await text('[data-inb="sprints-source"]')) && await st(() => !!document.querySelector('[data-inb-state="active"] [data-inb="sprints-source"]') && !document.querySelector('.fold [data-inb="sprints-source"], .fold[data-inb="sprints-source"]')));
46
- ok('SP2 the active sprint is drawn with its counts; future 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, '')))));
46
+ ok('SP2 the active sprint is drawn with its counts; the future sprint is listed; closed sprints are kept off the page', await st(() => !!document.querySelector('[data-inb-state="active"]') && !!document.querySelector('[data-inb-state="future"]') && !document.querySelector('[data-inb-state="closed"]') && !!document.querySelector('[data-inb="sprints-past"] [data-inb-past]') && ((m) => !!m && Number(m[1]) === 12 && Number(m[2]) + Number(m[3]) + Number(m[4]) === 12)(/Items(\d+):(\d+)done,(\d+)inprogress,(\d+)todo/.exec(document.querySelector('[data-inb="sprint-counts"]').textContent.replace(/\s+/g, '')))));
47
47
  // sprint-goal-summary: the goal (from Jira, or written from the tickets and why), the team summary, closed sprints read once
48
48
  ok('SP4 the sprint goal is written from the tickets, with Jira\'s goal only as a reference', await st(() => { const g = document.querySelector('[data-inb="sprint-summary"] [data-inb="sprint-goal"]'); return !!g && g.dataset.inbGoal === 'tickets' && /SPRINT GOAL · from the tickets/.test(g.textContent) && /In Jira: “Q4 delivery”/.test(g.textContent) && !/from Jira/.test(g.textContent); })
49
- && /from the tickets/.test(await text('[data-inb-state="future"] [data-inb="sprint-goal"]')) && !(await text('[data-inb-state="future"] [data-inb="sprint-goal"]')).includes('In Jira') && /from the tickets/.test(await text('[data-inb-state="closed"] [data-inb="sprint-goal"]')));
49
+ && /from the tickets/.test(await text('[data-inb-state="future"] [data-inb="sprint-goal"]')) && !(await text('[data-inb-state="future"] [data-inb="sprint-goal"]')).includes('In Jira'));
50
50
  ok('SP5 what the team is working on: a row per theme, one with no initiative', await st(() => document.querySelectorAll('[data-inb="sprint-themes"] [data-inb-theme]').length === 2 && !!document.querySelector('[data-inb-theme="none"]') && /No initiative/.test(document.querySelector('[data-inb-theme="none"]').textContent)));
51
- 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"]')));
51
+ // the sprint brief (mockup-c3-sprint-brief v3): where it stands, issues with a proposed plan, done, flow, asks; velocity; tickets behind a press
52
+ ok('SP7 the brief says where the sprint stands, with the day and the points', await st(() => { const v = document.querySelector('[data-inb="sprint-verdict"]'); return !!v && v.dataset.inbHealth === 'warn' && /At risk/.test(v.textContent) && /Day 7 of 14/.test(v.textContent) && /21 of 48 points/.test(v.textContent); })
53
+ && /Needs attention/.test(await text('[data-inb="sprint-summary"] .sh2')));
54
+ ok('SP8 each issue to resolve carries a proposed plan, when it is done, and Add to my Work', await st(() => { const r = [...document.querySelectorAll('[data-inb="sprint-issues"] [data-inb-sp-issue]')]; const b = r.find((x) => x.dataset.inbSpIssue === 'bad');
55
+ return r.length === 2 && !!b && /Blocked/.test(b.textContent) && b.querySelectorAll('[data-inb="sprint-plan"] ol li').length === 3 && /Done when: /.test(b.textContent) && !!b.querySelector('[data-inb-sp-addwork]'); }));
56
+ await page.click('[data-inb="sprint-issues"] [data-inb-sp-issue="bad"] [data-inb-sp-addwork]');
57
+ ok('SP9 Add to my Work files the issue in the Work inbox', await until(() => st(() => /Added to your Work inbox/.test(document.body.textContent))));
58
+ ok('SP10 completed work says what is not in Prod yet; the flow and the asks are drawn', await st(() => document.querySelectorAll('[data-inb="sprint-done"] [data-inb-sp-done]').length === 2 && /Not in Prod yet/.test(document.querySelector('[data-inb-sp-done="waiting"]').textContent)
59
+ && document.querySelectorAll('[data-inb="sprint-flow"] u').length >= 2 && document.querySelectorAll('[data-inb="sprint-asks"] li').length === 2));
60
+ ok('SP11 the velocity card draws the last four sprints and this one, with the average', await st(() => { const c = document.querySelector('[data-inb="sprint-velocity"]'); return !!c && c.querySelectorAll('svg.ini-vel rect.done').length === 5 && !!c.querySelector('line.avg') && /Average 27 points done a sprint, 77% of what was committed/.test(c.textContent); })
61
+ && /about 36 of 48 points/.test(await text('[data-inb="sprint-counts"]')));
62
+ ok('SP12 the tickets are hidden until Show all is pressed', await st(() => document.querySelector('[data-inb="sprint-ticket-list"]').hidden && /Show all 12 tickets/.test(document.querySelector('[data-inb="sprint-show-tickets"]').textContent)));
63
+ await page.click('[data-inb="sprint-show-tickets"]');
64
+ ok('SP13 Show all opens the list of every ticket', await until(() => st(() => !document.querySelector('[data-inb="sprint-ticket-list"]').hidden && document.querySelectorAll('[data-inb="sprint-ticket-list"] [data-inb-issue]').length === 12 && /Hide 12 tickets/.test(document.querySelector('[data-inb="sprint-show-tickets"]').textContent))));
65
+ await page.click('[data-inb="sprints-past-open"]');
66
+ await until(() => st(() => !!document.querySelector('[data-inb="sprints-past-sheet"] [data-inb-state="closed"]')));
67
+ const past = '[data-inb="sprints-past-sheet"] [data-inb-state="closed"]';
68
+ ok('SP6 a past sprint opens in a sheet: goal met, done of total, carried, what got in the way, read once', /5 of 7 done/.test(await text(past)) && /2 carried to/.test(await text(past)) && /Goal met/.test(await text(past))
69
+ && /from the tickets/.test(await text(past + ' [data-inb="sprint-goal"]')) && /waited 5 days on design/.test(await text(past + ' [data-inb="sprint-got"]'))
70
+ && /Read and summarised once on 29 Sep\. PMOS does not read a closed sprint again\./.test(await text(past + ' [data-inb="sprint-read-once"]')) && /Closed sprints/.test(await text('[data-inb="sprints-how"]')));
71
+ await page.keyboard.press('Escape');
53
72
  ok('SP3 the sprint burndown card draws the ideal and actual lines', await until(() => st(() => !!document.querySelector('[data-ini-tool-card="burndown"] svg.ini-burn path.ideal') && !!document.querySelector('[data-ini-tool-card="burndown"] svg.ini-burn path.act'))));
54
73
 
55
74
  /* ── an initiative's six tabs ── */
@@ -37,6 +37,17 @@ async function openPanel(page) {
37
37
  if (got && await page.$('[data-c3-all-sessions]')) await page.click('[data-c3-all-sessions]');
38
38
  }
39
39
  async function waitFor(page, fn, arg, ms = 15000) { try { await page.waitForFunction(fn, arg, { timeout: ms }); return true; } catch { return false; } }
40
+ /* the session sheet opens on the conversation (c3-session-conv.js); Terminal shows the live terminal */
41
+ const sheetOf = (i) => `[data-c3-session="${i}"]`;
42
+ async function toTerm(page, i) {
43
+ if (!await waitFor(page, (s) => !!document.querySelector(s), sheetOf(i))) return false;
44
+ await page.click('[data-c3-view="term"]');
45
+ return waitFor(page, (x) => !!document.querySelector(`[data-c3-session-term="${x}"] .xterm`), i);
46
+ }
47
+ async function openTerm(page, i) {
48
+ await page.evaluate((x) => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: { id: x } })), i);
49
+ return toTerm(page, i);
50
+ }
40
51
  const get = (p) => fetch(BASE + p).then((r) => r.json());
41
52
  // the agent CLI's home for this run: the conversation test writes a session record and a transcript here, as claude would
42
53
  const CLAUDE_HOME = path.join(T.STATE_DIR, 'claude-home');
@@ -104,23 +115,30 @@ function startShell(name) {
104
115
  /* ── C: Open the session stays on this page ── */
105
116
  const url0 = page.url();
106
117
  await page.click(`[data-blank-panel="${id}"] [data-open-session]`);
107
- ok('C1 the session opens in a terminal sheet', await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), id));
118
+ ok('C1 the session opens in a sheet, on its conversation', await waitFor(page, (i) => { const c = document.querySelector(`[data-c3-session="${i}"] [data-c3-conv]`); return !!c && !c.hidden && !document.querySelector('.xterm'); }, id));
119
+ ok('C1b a shell with no Claude in it says so and offers Start Claude here', await waitFor(page, () => /No Claude in this shell yet/.test((document.querySelector('[data-cv="none"]') || {}).textContent || '') && !!document.querySelector('[data-cv="start-claude"]')));
120
+ ok('C1c the view control has Conversation and Terminal, Conversation pressed', await page.evaluate(() => [...document.querySelectorAll('[data-c3-view]')].map((b) => b.dataset.c3View + ':' + b.getAttribute('aria-pressed')).join() === 'conv:true,term:false'));
121
+ ok('C1d Terminal shows the live terminal in the same sheet', await toTerm(page, id) && await page.evaluate(() => document.querySelector('[data-c3-conv]').hidden === true));
108
122
  ok('C2 the page stays on console3', page.url() === url0, page.url());
109
123
  await sleep(800);
110
124
  await page.click(`[data-c3-session-term="${id}"] .xterm`);
111
- await page.keyboard.type('echo c3-typed-$((40+2))\n');
125
+ // a claude that only echoes, so Start Claude here can be checked without a real agent
126
+ await page.keyboard.type('claude() { echo c3-start-$((3*3)); }; echo c3-typed-$((40+2))\n');
112
127
  ok('C2b what is typed in the sheet reaches the shell', !!(await until(async () => /c3-typed-42/.test((await get('/api/sessions/transcript?id=' + id + '&max=4000')).text || ''), 8000)));
113
128
  await page.keyboard.press('Escape');
114
129
  await sleep(300);
115
130
  ok('C3 Esc in the terminal goes to the shell, not the sheet', await page.evaluate((i) => !!document.querySelector(`[data-c3-session-term="${i}"]`), id));
116
131
  await page.keyboard.press('Control+u'); // the shell read that Esc as a Meta prefix: this key takes it, so the next line starts clean
132
+ await page.click('[data-c3-view="conv"]');
133
+ ok('C3b Conversation comes back, the terminal stays mounted but hidden', await waitFor(page, (i) => { const c = document.querySelector('[data-c3-conv]'); const t = document.querySelector(`[data-c3-session-term="${i}"]`); return !!c && !c.hidden && !!t && !!t.querySelector('.xterm') && t.closest('[data-c3-view-pane]').hidden; }, id));
134
+ await page.click('[data-cv="start-claude"]');
135
+ ok('C3c Start Claude here types claude into the shell', !!(await until(async () => /c3-start-9/.test((await get('/api/sessions/transcript?id=' + id + '&max=4000')).text || ''), 8000)));
117
136
  await page.click('[data-shell="sheet"] [aria-label="Close"]');
118
137
  ok('C4 the close button closes the terminal sheet', await waitFor(page, () => !document.querySelector('[data-c3-session-term]')));
119
138
  ok('C5 closing the sheet leaves the session running', await until(async () => ((await get('/api/sessions')).sessions || []).some((s) => s.id === id && s.state !== 'exited')));
120
139
 
121
140
  /* ── G: the scratchpad and the size control on the terminal sheet (c3-term-pad-size) ── */
122
- await page.evaluate((i) => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: { id: i } })), id);
123
- const g0 = await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), id);
141
+ const g0 = await openTerm(page, id);
124
142
  ok('G0 the terminal sheet opens for the pad checks', g0 || !!(await page.$('[data-c3-size-seg]')));
125
143
  const sizes = await page.$$eval('[data-c3-size-seg] [data-c3-size]', (b) => b.map((x) => x.dataset.c3Size));
126
144
  ok('G1 the size control has 4 options', sizes.join() === 'normal,half,two-thirds,whole', sizes.join());
@@ -137,8 +155,7 @@ function startShell(name) {
137
155
  await page.click('[data-c3-size="half"]');
138
156
  await page.click('[data-shell="sheet"] [aria-label="Close"]');
139
157
  await waitFor(page, () => !document.querySelector('[data-c3-session-term]'));
140
- await page.evaluate((i) => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: { id: i } })), id);
141
- await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), id);
158
+ await openTerm(page, id);
142
159
  await sleep(200);
143
160
  ok('G4 the size choice is kept after closing and opening again', Math.abs((await wid()) - vw / 2) < 3, String(await wid()));
144
161
  ok('G4b it is kept in localStorage', await page.evaluate(() => localStorage.getItem('pmos.workspace.c3.term-size') === 'half'));
@@ -158,7 +175,7 @@ function startShell(name) {
158
175
  await page.fill('[data-c3-pad="text"]', 'flushed-on-close');
159
176
  await page.click('[data-shell="sheet"] [aria-label="Close"]');
160
177
  ok('G8 closing the sheet saves a pad edit at once', !!(await until(async () => (await get('/api/scratchpad?session=' + id)).text === 'flushed-on-close', 2000)));
161
- await page.evaluate((i) => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: { id: i } })), id);
178
+ await openTerm(page, id);
162
179
  await waitFor(page, () => !!document.querySelector('[data-c3-pad="text"]'));
163
180
  ok('G9 the pad loads its text when the terminal opens again', await waitFor(page, () => document.querySelector('[data-c3-pad="text"]').value === 'flushed-on-close'));
164
181
  /* the grip above the pad resizes it: a drag up makes the pad taller and the terminal shorter; the height is kept */
@@ -183,9 +200,9 @@ function startShell(name) {
183
200
  /* ── D: End asks first, then ends ── */
184
201
  // Sessions opens the terminal of the session opened last while it runs (PM, 2026-10-13); its "All sessions" opens the panel
185
202
  await page.click('[data-c3pg="agent-button"]');
186
- ok('D0 Sessions opens the terminal of the session opened last', await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), id));
203
+ ok('D0 Sessions opens the session opened last', await waitFor(page, (s) => !!document.querySelector(s + ' [data-c3-conv]'), sheetOf(id)));
187
204
  await page.click('[data-c3-all-sessions]');
188
- ok('D0a All sessions in the terminal sheet opens the panel', await waitFor(page, (s) => !!document.querySelector(s), sel) && !(await page.$('[data-c3-session-term]')));
205
+ ok('D0a All sessions in the session sheet opens the panel', await waitFor(page, (s) => !!document.querySelector(s), sel) && !(await page.$('[data-c3-session]')));
189
206
  ok('D0b it opens on the session opened last: its row is open and marked', await waitFor(page, (i) => { const li = document.querySelector(`[data-c3t="mine"] [data-run="${i}"]`); const end = document.querySelector(`[data-end-session="${i}"]`); return !!li && li.hasAttribute('data-last-open') && !!end && end.offsetParent !== null; }, id)
190
207
  && await page.evaluate((i) => localStorage.getItem('pmos.workspace.c3.last-session') === i, id));
191
208
  if (!(await page.isVisible(`[data-end-session="${id}"]`).catch(() => false))) await page.click(sel + ' .nb');
@@ -232,6 +249,47 @@ function startShell(name) {
232
249
  return me && tool && !!bot && !!bot.querySelector('li b') && !!bot.querySelector('li code') && !/not shown/.test(f.textContent);
233
250
  }, feedSel, 10000));
234
251
 
252
+ /* ── S: the session sheet's conversation, from the same transcript ── */
253
+ await page.evaluate((i) => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: { id: i } })), id);
254
+ const cvSel = sheetOf(id) + ' [data-c3-conv]';
255
+ ok('S1 the sheet shows the prompt, a folded tool line and the reply', await waitFor(page, (s) => {
256
+ const f = document.querySelector(s); if (!f) return false;
257
+ const me = [...f.querySelectorAll('[data-chat="me"]')].some((n) => n.textContent === 'What changed today?');
258
+ const tool = f.querySelector('details[data-chat="tool"]');
259
+ const bot = f.querySelector('[data-chat="bot"]');
260
+ return me && !!tool && !tool.open && /Used Bash/.test(tool.textContent) && !!bot && !!bot.querySelector('li b') && !/not shown/.test(f.textContent);
261
+ }, cvSel, 10000));
262
+ ok('S2 the tool line lists each call with its target', await page.evaluate((s) => [...document.querySelectorAll(s + ' details[data-chat="tool"] li')].map((li) => li.querySelector('b').textContent + ' ' + ((li.querySelector('span') || {}).textContent || '')).join() === 'Bash git log', cvSel));
263
+ ok('S3 the status reads Your turn and the meta names the model', await page.evaluate(() => (document.querySelector('[data-cv="status"]') || {}).textContent === 'Your turn' && /claude-test/.test((document.querySelector('[data-cv="meta"]') || {}).textContent || '')));
264
+ await page.fill('[data-cv="msg"]', 'echo c3-conv-$((5*5))');
265
+ await page.press('[data-cv="msg"]', 'Enter');
266
+ ok('S4 a sent message shows at once', await waitFor(page, () => [...document.querySelectorAll('[data-c3-conv] [data-chat="me"]')].some((n) => /c3-conv-/.test(n.textContent)), null, 3000));
267
+ ok('S4b the send box types into the session and presses Enter', !!(await until(async () => /^c3-conv-25/m.test((await get('/api/sessions/transcript?id=' + id + '&max=4000')).text || ''), 8000)));
268
+ // Claude asks before a tool: the transcript holds an open call and the pane shows Claude Code's prompt
269
+ fs.appendFileSync(path.join(proj, 'conv-test-1.jsonl'), rec({ type: 'assistant', message: { id: 'msg_3', model: 'claude-test', role: 'assistant', content: [{ type: 'tool_use', id: 't2', name: 'Bash', input: { command: 'rm notes.txt' } }] } }) + '\n');
270
+ await page.fill('[data-cv="msg"]', "clear; printf 'Do you want to proceed?\\n> 1. Yes\\n 2. No, and tell Claude what to do differently\\n'");
271
+ await page.press('[data-cv="msg"]', 'Enter');
272
+ ok('S5 the allow prompt shows as a card with its options', await waitFor(page, () => {
273
+ const c = document.querySelector('[data-c3-conv] [data-cv="ask"]'); if (!c) return false;
274
+ const o = [...c.querySelectorAll('[data-cv-opt]')].map((b) => b.dataset.cvOpt + ':' + b.textContent).join('|');
275
+ return /Do you want to proceed\?/.test(c.textContent) && o === '1:Yes|2:No, and tell Claude what to do differently' && !!c.querySelector('[data-cv="ask-term"]');
276
+ }, null, 20000));
277
+ if (!results[results.length - 1].cond) { const c = await get('/api/sessions/conversation?id=' + id); console.log(' conversation:', JSON.stringify({ state: c.state, turn: c.turn, openCall: c.openCall, ask: c.ask }), JSON.stringify(((await get('/api/sessions/transcript?id=' + id + '&max=600')).text || '').slice(-300))); }
278
+ ok('S5b the status reads Needs you', await page.evaluate(() => (document.querySelector('[data-cv="status"]') || {}).textContent === 'Needs you'));
279
+ await page.click('[data-cv-opt="2"]', { timeout: 3000 }).catch(() => {});
280
+ await sleep(500);
281
+ await page.fill('[data-cv="msg"]', '; echo c3-opt-$((2*4))');
282
+ await page.press('[data-cv="msg"]', 'Enter');
283
+ ok('S5c an option button types its number into the session', !!(await until(async () => /2; echo c3-opt/.test((await get('/api/sessions/transcript?id=' + id + '&max=6000')).text || ''), 8000)));
284
+ await page.click('[data-cv="ask-term"]').catch(() => {});
285
+ ok('S6 Answer in the terminal switches to the terminal', await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`) && document.querySelector('[data-c3-view="term"]').getAttribute('aria-pressed') === 'true', id));
286
+ await page.click('[data-shell="sheet"] [aria-label="Close"]');
287
+ await waitFor(page, () => !document.querySelector('[data-c3-session]'));
288
+ // the panel row was open before the sheet: open it again for End
289
+ // (it opens on the session opened last, with its row open)
290
+ await openPanel(page);
291
+ if (!await waitFor(page, (i) => { const b = document.querySelector(`[data-end-session="${i}"]`); return !!b && b.offsetParent !== null; }, id, 5000)) await page.click(sel + ' .nb');
292
+
235
293
  await page.click(`[data-end-session="${id}"]`);
236
294
  await page.click(`[data-end-yes="${id}"]`);
237
295
  const gone = async () => !((await get('/api/sessions')).sessions || []).some((s) => s.id === id);
@@ -255,9 +313,9 @@ function startShell(name) {
255
313
  const made = await until(async () => ((await get('/api/sessions')).sessions || []).find((s) => !before.has(s.id) && s.name === 'c3 new shell'), 10000);
256
314
  ok('F3 Start starts the session on the server', !!made);
257
315
  ok('F3b it starts on the board chosen in the sheet', !!made && made.ws === opts.find((o) => o.on).v, JSON.stringify(made && made.ws));
258
- ok('F4 its terminal opens in a sheet on console3', !!made && await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), made.id) && page.url() === url0, page.url());
316
+ ok('F4 it opens in a sheet on console3', !!made && await waitFor(page, (s) => !!document.querySelector(s + ' [data-c3-conv]'), sheetOf(made.id)) && page.url() === url0, page.url());
259
317
  await page.click('[data-shell="sheet"] [aria-label="Close"]');
260
- await waitFor(page, () => !document.querySelector('[data-c3-session-term]'));
318
+ await waitFor(page, () => !document.querySelector('[data-c3-session]'));
261
319
  await page.evaluate(() => window.dispatchEvent(new CustomEvent('pmos:open-session', { detail: {} })));
262
320
  ok('F5 an open-session request with no session opens the new-session sheet, not the classic app', await waitFor(page, () => !!document.querySelector('[data-c3ns="form"]')) && page.url() === url0, page.url());
263
321
  await page.click('[data-shell="sheet"] [aria-label="Close"]');
@@ -87,8 +87,8 @@ const W = '[data-pm-work]';
87
87
  if (!ready) return finish(1);
88
88
 
89
89
  /* the six views and their counts (R1) */
90
- const readBar = () => st((w) => [...document.querySelectorAll(w + ' [data-pm-lenses] [data-lens]')].map((b) => [b.getAttribute('data-lens'), b.textContent.replace(/\d+$/, '').trim()]), W);
91
- // A redraw puts a new bar in the header and the quiet layout moves it beside Add work 50 ms later; read it once it has settled.
90
+ // only the bar on screen: a redraw puts a new bar in the header, hidden there (.calbar.qt-moved), until the quiet layout moves it beside Add work
91
+ const readBar = () => st((w) => [...document.querySelectorAll(w + ' [data-pm-lenses] [data-lens]')].filter((b) => b.offsetParent !== null).map((b) => [b.getAttribute('data-lens'), b.textContent.replace(/\d+$/, '').trim()]), W);
92
92
  await until(async () => (await readBar()).length === VIEWS.length, 10000, 100);
93
93
  const bar = await readBar();
94
94
  ok('the bar has the six views in order: Inbox, Today, Upcoming, Waiting, Someday, Review', JSON.stringify(bar.map((b) => b[0])) === JSON.stringify(VIEWS) && JSON.stringify(bar.map((b) => b[1])) === JSON.stringify(LABELS), JSON.stringify(bar));
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Sessions in console3: start, open, message, read and end your sessions"
4
- description: "How the Sessions panel in console3 groups your sessions, how to start a session, how to open a session's terminal without leaving console3, how to send a message to a session, and how to end a session."
5
- timestamp: 2026-10-13
4
+ description: "How the Sessions panel in console3 groups your sessions, how to start a session, how to open a session as a conversation or as its terminal without leaving console3, how to send a message to a session, and how to end a session."
5
+ timestamp: 2026-10-14
6
6
  ---
7
7
 
8
8
  # Sessions in console3: start, open, message, read and end your sessions
9
9
 
10
10
  Press the **Sessions** button in the console3 header to open the panel. It is the bolt icon with no label. Point at it to see its name. When a session needs you, a count shows on it. It shows your sessions in four groups: **On a card**, **Started by me**, **Past (7 days)** and **Automatic**.
11
11
 
12
- When the session that you opened last is still running, the **Sessions** button opens its terminal directly. To see all your sessions, press **All sessions** at the top of the terminal sheet. If that session has ended, the button opens the panel.
12
+ When the session that you opened last is still running, the **Sessions** button opens that session directly, on its conversation. To see all your sessions, press **All sessions** at the top of the session sheet. If that session has ended, the button opens the panel.
13
13
 
14
14
  The panel opens on the session that you opened last. Its row is open, so you can read the conversation and send a message without searching for it. A session counts as opened when you open its row or its terminal.
15
15
 
@@ -32,7 +32,7 @@ You start a session in console3. You do not need the classic app.
32
32
  - **Automatic**: the server selects the tree.
33
33
  - A listed tree: the session runs in that working tree.
34
34
  - **New branch**: type the branch name. The session runs in its own working copy of the new branch.
35
- 6. Press **Start**. The session starts, and its terminal opens in a sheet on the same page. If you selected a different board, the dock changes to that board.
35
+ 6. Press **Start**. The session starts, and it opens in a sheet on the same page. If you selected a different board, the dock changes to that board.
36
36
 
37
37
  If the server cannot start the session, the sheet shows the reason.
38
38
 
@@ -47,7 +47,31 @@ If the server finds that the process of a session stopped (for example, after a
47
47
  1. Press the row to open it.
48
48
  2. Press **Open the session**.
49
49
 
50
- The terminal of the session opens in a panel on the same page. You can type in it. When the terminal has focus, Esc and Tab go to the terminal. To close the panel, press its close button. The session continues to run.
50
+ The session opens in a sheet on the same page, on its conversation. To close the sheet, press its close button. The session continues to run.
51
+
52
+ Two buttons at the top of the sheet change the view:
53
+
54
+ - **Conversation**: your messages and the replies of Claude, with a box to send a message. This is the view that opens first.
55
+ - **Terminal**: the live terminal of the session. You can type in it. When the terminal has focus, Esc and Tab go to the terminal. Use it for the things that the conversation does not show, for example a menu of Claude Code or the full output of a command.
56
+
57
+ Press **Conversation** to go back. The terminal stays connected while the sheet is open, so nothing restarts when you change the view.
58
+
59
+ ### The conversation
60
+
61
+ The conversation comes from the transcript that Claude Code writes on this computer. It shows the words of Claude as Claude wrote them. Nothing is summarised, and nothing is sent to a different place.
62
+
63
+ - Your messages are on the right. The replies of Claude are on the left. Lists, bold text and code show as formatted text.
64
+ - A grey line, for example "Used Bash, Read ×2", shows a run of tool calls. Press it to see each call and its target, for example the file or the command.
65
+ - While Claude works, a line says so. Press the stop button next to the message box to stop Claude. It types Esc into the session.
66
+ - When Claude asks before it uses a tool, a card shows the question and its options, for example **Yes** and **No**. Press an option to answer. You can also press **Answer in the terminal**.
67
+ - Type a message in the box and press Enter to send it. Shift Enter starts a new line. **/compact** and **/clear** put that command in the box.
68
+ - The tag above the conversation shows the state: **Working**, **Needs you**, **Your turn**, **Shell** or **Ended**.
69
+
70
+ A shell where Claude has not started says "No Claude in this shell yet". Press **Start Claude here** to start Claude in that shell. Its conversation then shows in this view.
71
+
72
+ If PMOS cannot find the transcript of a Claude session, the view says so, and it does not show the transcript of a different session. Read the session in the terminal. PMOS looks for transcripts in `~/.claude`, in each `~/.claude-*` profile folder, and in the folder that `CLAUDE_CONFIG_DIR` names.
73
+
74
+ At **2/3** and **Whole**, the sheet also shows a list of your running sessions at the left. Press a session to open it.
51
75
 
52
76
  ### The scratchpad
53
77
 
@@ -58,11 +82,11 @@ Below the terminal is a scratchpad. It keeps text for this session only. The tex
58
82
  - To make the pad taller or shorter, drag the grip on the line above it. Or select the grip and press the up arrow or the down arrow. The terminal gets the remaining room. The page remembers the height. To go back to the default height, double-click the grip.
59
83
  - Esc in the pad closes the panel. Esc in the terminal goes to the terminal.
60
84
 
61
- ### The size of the terminal
85
+ ### The size of the sheet
62
86
 
63
- Four buttons in the header of the panel set its width: **Normal**, **Half**, **2/3** and **Whole**. Half is half of the window. 2/3 is two thirds of the window. Whole is the full window. The terminal fits the new size. The page remembers your choice for the next terminal you open. On a phone the panel is already full width, so the buttons are not shown.
87
+ Four buttons in the header of the sheet set its width: **Normal**, **Half**, **2/3** and **Whole**. Half is half of the window. 2/3 is two thirds of the window. Whole is the full window. The terminal fits the new size. The page remembers your choice for the next session you open. On a phone the panel is already full width, so the buttons are not shown.
64
88
 
65
- The **Open the session** link in the sessions dock opens the same panel.
89
+ The **Open the session** link in the sessions dock opens the same sheet.
66
90
 
67
91
  ## Send a message to a session
68
92
 
@@ -78,7 +102,7 @@ The panel shows the conversation of a Claude session:
78
102
 
79
103
  - Your messages are on the right.
80
104
  - The replies of Claude are on the left. Lists, bold text and code show as formatted text.
81
- - A grey line, for example "Used Bash, Read ×2", shows the tools that Claude used. The input of a tool does not show.
105
+ - A grey line, for example "Used Bash, Read ×2", shows the tools that Claude used. The session sheet also shows the target of each call.
82
106
  - While Claude works, a grey line says so.
83
107
 
84
108
  The panel reads the conversation from the transcript that Claude Code writes on this computer. Nothing is sent to a different place. The panel updates while it is open.
@@ -126,7 +126,19 @@ The check then writes a goal and a short summary for each sprint, from the ticke
126
126
  - The check always writes the goal from the sprint's tickets, because Jira's sprint goal is often empty, too broad (for example "Q4 delivery") or incomplete. The tab shows it as **from the tickets**.
127
127
  - When Jira has a sprint goal, the tab shows it under the goal as a reference: **In Jira: "…"**.
128
128
  - The active and the next sprint are summarised again at each check.
129
- - A closed sprint is read and summarised once. After that, the check does not search it again and keeps its first summary.
129
+ - A closed sprint is read and summarised once. After that, the check does not search it again and keeps its first summary. The check also says if the goal was met and what got in the way.
130
+
131
+ ## The sprint brief
132
+
133
+ The active sprint opens with a brief for the PM, not a list of tickets:
134
+
135
+ - **Where the sprint stands**: on track, at risk or off track, about how much will carry over, and which work is exposed.
136
+ - **Issues to resolve**: each blocked or stuck ticket, why it matters, and a proposed plan to move it to done: who to ask, the decision to make and the fallback. **Add to my Work** puts the issue in your Work inbox. Nothing is written to Jira.
137
+ - **Completed so far**: the outcomes done in the sprint. Work that is done but not yet in production says **Not in Prod yet**.
138
+ - **Where the tickets are**: one bar for the sprint's tickets, by Jira status.
139
+ - **For you**: the decisions only you can take this week.
140
+
141
+ PMOS counts the numbers itself from the Jira read: the points done and committed, the tickets with no estimate, the velocity of the last four closed sprints and the forecast. The check writes the words. The sprint's tickets stay behind **Show all**. Closed sprints are under **Past sprints** on the right, and each one opens in a sheet.
130
142
 
131
143
  The check writes `<state>/tracker/jira/_sprint-notes.md` with:
132
144
 
@@ -100,7 +100,7 @@ An item goes to the first view that fits these rules, in this order. Parked is S
100
100
 
101
101
  Open an item that shows **Agent can:**. The case file shows **What the agent will do**.
102
102
 
103
- The offer is on Jira and WBS items with a suggestion, and on each item the morning brief suggests for you (for example a Slack reply or an email). On a brief item the offer is the reply or the note that the item asks for, and the draft goes back to the place the item came from, only when you post it.
103
+ The offer is on Jira and WBS items with a suggestion, and on each item the morning brief suggests for you (for example a Slack reply or an email), also on the days after, while you keep the item in Work. On a brief item the offer is the reply or the note that the item asks for, and the draft goes back to the place the item came from, only when you post it.
104
104
 
105
105
  An item you added yourself shows **Agent can draft a first version**. The agent drafts what the item asks for (a document, a plan or a recommendation), and the draft stays on the item. It has no place to post to: read it, keep it, or send it back with a note.
106
106
 
@@ -126,6 +126,9 @@ When the draft is ready, the case file quotes it. Select **Open the draft** to r
126
126
  - **Post it to** the ticket posts the exact text. Nothing goes out until you hold to confirm.
127
127
  - **Send it back with a note** returns the draft to the agent. Type what to change and press Enter.
128
128
  - **Keep it as a draft** leaves the draft as it is.
129
+ - **Mark it done** closes the item. Nothing is posted.
130
+
131
+ To change the text yourself before you post it, select **Edit the draft**, change the text and select **Save the draft**. The case file then says **Edited by you**, and **Post it to** the ticket posts exactly your text.
129
132
 
130
133
  The agent only drafts. It never posts to Jira until you approve the draft.
131
134
 
@@ -249,6 +249,35 @@ function decide(run, taskId, op, now) {
249
249
  });
250
250
  }
251
251
 
252
+ /** The PM's own edit of a draft that waits for them (initiative c3-work-agent, amendment 13). The edited text replaces the
253
+ * body, so an approve posts exactly what the PM saved. The agent's first text stays in `agent_body`. */
254
+ function edit(run, taskId, body, now) {
255
+ const b = typeof body === 'string' ? body.trim() : '';
256
+ return withTask(run, taskId, (t) => {
257
+ if (t.status !== 'review' || !t.draft) return `task ${taskId} is ${t.status} — only a draft waiting for you can be edited`;
258
+ if (!b) return 'the draft cannot be empty — keep some text, or send it back with a note';
259
+ if (b.length > 8000) return 'the draft is over 8000 characters';
260
+ if (b === t.draft.body) return null;
261
+ if (t.draft.agent_body === undefined) t.draft.agent_body = t.draft.body;
262
+ t.draft.body = b; t.draft.edited_at = iso(now);
263
+ return null;
264
+ });
265
+ }
266
+
267
+ /** Approve a draft of an earlier day's run (amendment 13). The job CLI reads today's run only, so the approved task joins
268
+ * today's run as `sending`, with its draft as it is. `task` is the task of the earlier run. */
269
+ function carry(run, task, now) {
270
+ if (!run) return { ok: false, error: 'no day run today' };
271
+ if (!task || !TASK_RE.test(String(task.id || ''))) return { ok: false, error: 'no task to carry' };
272
+ if (task.status !== 'review' || !task.draft) return { ok: false, error: `task ${task.id} is ${task.status} — only a draft waiting for you can be approved` };
273
+ const next = clone(run);
274
+ if (findTask(next, task.id)) return { ok: false, error: `task ${task.id} is already in today's run` };
275
+ const t = clone(task);
276
+ Object.assign(t, { status: 'sending', session_id: null, prev_session: null, started_at: null, error: null, decided: { action: 'approve', at: iso(now) } });
277
+ next.tasks.push(t);
278
+ return { ok: true, run: next, task: task.id };
279
+ }
280
+
252
281
  /** The send session reports the approved step done, with the link to what it posted. */
253
282
  function sent(run, taskId, s, now) {
254
283
  return withTask(run, taskId, (t) => {
@@ -413,5 +442,5 @@ function checkDone(run, { found = 0, resolved = 0 } = {}, now) {
413
442
  }
414
443
 
415
444
  module.exports = { MAX_PARALLEL, MAX_REVISIONS, MAX_CHECK_LOG, MAX_INTERVAL_MIN, STATUSES, KINDS, ACTIONS, TASK_RE,
416
- newRun, readRun, findTask, modeOf, addTasks, addBriefed, addSend, toStart, started, deliver, decide, sent, fail, stop, withdraw, sessionEnded, sessionStalled, stage, intervalMin,
445
+ newRun, readRun, findTask, modeOf, addTasks, addBriefed, addSend, toStart, started, deliver, decide, edit, carry, sent, fail, stop, withdraw, sessionEnded, sessionStalled, stage, intervalMin,
417
446
  checkDue, checkStarted, checkDone };
@@ -15,7 +15,8 @@
15
15
  * filterRows(rows, query) → { total, offset, limit, rows, facets }
16
16
  * health(rows) → 'ok' | 'warn' | 'bad'
17
17
  * counts(rows, workflow) → { total, open, byCat, byStage, byStatus, risk, late }
18
- * sprintsOf(snapshots, onBoard, whole?, notes?) → { source, readAt, sprints } (notes: each sprint's goal and summary, sprint-goal-summary)
18
+ * sprintsOf(snapshots, onBoard, whole?, notes?, { today }?) → { source, readAt, sprints, velocity } (notes: each sprint's goal, summary
19
+ * and brief; points, flow, velocity and the active sprint's pace are counted here, sprint-goal-summary)
19
20
  * INI_TEMPLATES · isBuildStage · templateBuilds · boardSettings(board) the per-initiative workflows (console3-initiatives-parity)
20
21
  * workflowOf(board, id, folded) → the stages an initiative runs: its own (ini-workflow), else the board's
21
22
  * applyIniWorkflow(board, wfNow, stage, template, stages, moveTo) → { ok, template, workflow, moved } | { ok:false, status, error }
@@ -582,7 +583,24 @@ function counts(rows, workflow) {
582
583
  * lists EVERY issue Jira holds in it, whoever it is assigned to; an issue no initiative of the board holds has initiative null
583
584
  * (jira-sprint-scope). Without it, a sprint lists only the board's own tickets.
584
585
  */
585
- function sprintsOf(snapshots, onBoard = () => true, whole = null, notes = null) {
586
+ const ptsOf = (t) => (typeof t.points === 'number' && Number.isFinite(t.points) && t.points >= 0 ? t.points : null);
587
+ const DAY_MS = 864e5;
588
+ const dayNum = (iso) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || '')); return m ? Date.UTC(+m[1], +m[2] - 1, +m[3]) / DAY_MS : null; };
589
+ /** A sprint's points from its tickets: committed (every estimated ticket), done, and how many tickets have no estimate. */
590
+ function pointsOf(issues) {
591
+ const est = issues.filter((i) => i.points != null);
592
+ if (!est.length) return null;
593
+ const sum = (a) => Math.round(a.reduce((x, i) => x + i.points, 0) * 10) / 10;
594
+ return { committed: sum(est), done: sum(est.filter((i) => i.category === 'done')), unestimated: issues.length - est.length };
595
+ }
596
+ /** Where the tickets are: one entry per Jira status, to do first, then in progress, then done. */
597
+ function flowOf(issues) {
598
+ const by = new Map();
599
+ for (const i of issues) { const k = i.status || i.category; if (!by.has(k)) by.set(k, { status: k, category: i.category, n: 0 }); by.get(k).n++; }
600
+ const rank = { todo: 0, doing: 1, done: 2 };
601
+ return [...by.values()].sort((a, b) => (rank[a.category] - rank[b.category]) || (b.n - a.n));
602
+ }
603
+ function sprintsOf(snapshots, onBoard = () => true, whole = null, notes = null, opts = {}) {
586
604
  const byName = new Map();
587
605
  let readAt = null;
588
606
  let any = false;
@@ -599,7 +617,7 @@ function sprintsOf(snapshots, onBoard = () => true, whole = null, notes = null)
599
617
  if (!sp.start && typeof t.sprint.start === 'string' && t.sprint.start) sp.start = t.sprint.start; // the sprint's dates, as Jira holds them
600
618
  if (!sp.end && typeof t.sprint.end === 'string' && t.sprint.end) sp.end = t.sprint.end;
601
619
  const category = ['todo', 'doing', 'done'].includes(t.category) ? t.category : 'todo';
602
- sp.issues.push({ key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: s.initiative, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null });
620
+ sp.issues.push({ key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: s.initiative, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null, points: ptsOf(t) });
603
621
  sp.counts[category]++;
604
622
  }
605
623
  }
@@ -617,7 +635,7 @@ function sprintsOf(snapshots, onBoard = () => true, whole = null, notes = null)
617
635
  ...all.map((t) => {
618
636
  const category = ['todo', 'doing', 'done'].includes(t.category) ? t.category : 'todo';
619
637
  const o = own.get(t.key);
620
- return { key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: o ? o.initiative : null, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null };
638
+ return { key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: o ? o.initiative : null, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null, points: ptsOf(t) };
621
639
  }),
622
640
  ...sp.issues.filter((i) => !keys.has(i.key)), // a board ticket the sprint read has not seen yet keeps its own reading
623
641
  ];
@@ -640,7 +658,7 @@ function sprintsOf(snapshots, onBoard = () => true, whole = null, notes = null)
640
658
  if (!(n.themes || []).some((t) => t && t.initiative && onBoard(t.initiative))) continue;
641
659
  const issues = wt.filter((t) => t && t.key && t.sprint && same(t.sprint, n)).map((t) => {
642
660
  const category = ['todo', 'doing', 'done'].includes(t.category) ? t.category : 'todo';
643
- return { key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: iniOf.get(t.key) || null, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null };
661
+ return { key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: iniOf.get(t.key) || null, flagged: !!t.flagged, reason: t.flagged && t.reason ? String(t.reason) : null, points: ptsOf(t) };
644
662
  });
645
663
  const counts = { todo: 0, doing: 0, done: 0 };
646
664
  for (const i of issues) counts[i.category]++;
@@ -663,16 +681,42 @@ function sprintsOf(snapshots, onBoard = () => true, whole = null, notes = null)
663
681
  sp.summarisedTickets = Number.isInteger(n.tickets) ? n.tickets : null;
664
682
  sp.closed = sp.state === 'closed' && n.closed ? { ...n.closed } : null;
665
683
  sp.frozen = sp.state === 'closed' && n.state === 'closed';
684
+ sp.brief = sp.state !== 'closed' && n.brief ? n.brief : null;
685
+ sp.hit = sp.state === 'closed' && n.hit ? n.hit : null;
686
+ sp.got = sp.state === 'closed' && n.got ? n.got : null;
666
687
  } else {
667
688
  sp.goal = null;
668
689
  sp.jiraGoal = jg;
669
- sp.summary = null; sp.themes = []; sp.writtenAt = null; sp.summarisedTickets = null; sp.closed = null; sp.frozen = false;
690
+ sp.summary = null; sp.themes = []; sp.writtenAt = null; sp.summarisedTickets = null; sp.closed = null; sp.frozen = false; sp.brief = null; sp.hit = null; sp.got = null;
670
691
  }
671
692
  }
672
693
  const order = { active: 0, future: 1, closed: 2 };
673
694
  const sprints = [...byName.values()].sort((a, b) => ((order[a.state] ?? 3) - (order[b.state] ?? 3)) || a.name.localeCompare(b.name));
674
695
  for (const sp of sprints) sp.issues.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }));
675
- return { source: any ? 'jira' : 'none', readAt, sprints };
696
+ // Counted by PMOS, never written by the agent: each sprint's points and status flow, the velocity of the last four closed
697
+ // sprints, and the active sprint's pace and forecast (the sprint brief).
698
+ for (const sp of sprints) { sp.points = pointsOf(sp.issues); sp.flow = flowOf(sp.issues); }
699
+ const velocity = sprints.filter((sp) => sp.state === 'closed').map((sp) => {
700
+ let p = sp.points ? { committed: sp.points.committed, done: sp.points.done } : null;
701
+ const m = !p && sp.closed && /(\d+(?:\.\d+)?)\s*of\s*(\d+(?:\.\d+)?)/.exec(String(sp.closed.points || ''));
702
+ if (m) p = { done: Number(m[1]), committed: Number(m[2]) };
703
+ return p ? { name: sp.name, end: sp.end || '', ...p } : null;
704
+ }).filter(Boolean).sort((a, b) => (a.end || '').localeCompare(b.end || '') || a.name.localeCompare(b.name, undefined, { numeric: true })).slice(-4);
705
+ const active = sprints.find((sp) => sp.state === 'active');
706
+ if (active) {
707
+ const today = dayNum(opts.today) ?? Math.floor(Date.now() / DAY_MS);
708
+ const a = dayNum(active.start); const z = dayNum(active.end);
709
+ const pace = { day: null, days: null, daysLeft: null, avg: null, hitRate: null, forecast: null };
710
+ if (a != null && z != null && z > a) { pace.days = z - a; pace.day = Math.min(pace.days, Math.max(0, today - a + 1)); pace.daysLeft = Math.max(0, z - today); }
711
+ if (velocity.length) {
712
+ const done = velocity.reduce((x, v) => x + v.done, 0); const com = velocity.reduce((x, v) => x + v.committed, 0);
713
+ pace.avg = Math.round(done / velocity.length * 10) / 10;
714
+ pace.hitRate = com ? Math.round(done / com * 100) / 100 : null;
715
+ if (active.points && pace.days) pace.forecast = Math.min(active.points.committed, Math.round(active.points.done + pace.avg * pace.daysLeft / pace.days));
716
+ }
717
+ active.pace = pace;
718
+ }
719
+ return { source: any ? 'jira' : 'none', readAt, sprints, velocity };
676
720
  }
677
721
 
678
722
  module.exports = {