create-pmos 0.182.0 → 0.184.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.182.0",
3
+ "version": "0.184.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@1400593
1
+ kernel-source: pmos@14635e7
2
2
  assembled: 2026-10-13
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-13T03:18:01Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T04:43:18Z"}
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-13T03:18:01Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T04:43:18Z"}
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-13T03:18:01Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T04:43:18Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -11,7 +11,9 @@
11
11
  * meeting a Later today row and the morning card open the meeting's sheet with its brief (GET /api/meeting-focus)
12
12
  * record the red button shows the recording line and the notes being taken (just heard, decisions); Stop brings the bar back
13
13
  * debrief 11:25 shows the decisions and actions of the recorded Checkout sync; a meeting with no recording asks you to tell PMOS
14
- * desk Full console shows the desktop grid and survives a reload; More > Phone companion brings it back
14
+ * nodesk there is no Full console; a phone that chose it before 2026-10-13 shows the companion again
15
+ * focus the meeting companion opens over the companion and Back to your day closes it
16
+ * narrow a long title and a long list of people never widen the companion past the screen
15
17
  * desktop at 1280 wide no companion is drawn
16
18
  * 620 the companion shows up to 640 wide, where the phone styling starts
17
19
  * No script error may be raised.
@@ -131,6 +133,15 @@ const writes = (p) => (window.__c3demoWrites || []).filter((w) => w.path === p |
131
133
  await page.click('[data-c3ph-rec-meeting]');
132
134
  ok('moments: Record starts recording Checkout sync', await waitFor(page, () => /Checkout sync/.test(document.querySelector('[data-c3ph-recbar]')?.textContent || ''), null, 8000));
133
135
  ok('moments: while recording, the card opens the meeting companion', await waitFor(page, () => !!document.querySelector('[data-c3ph-moment] [data-c3ph-focus]'), null, 5000));
136
+ const fbtn = await waitFor(page, () => !!document.querySelector('[data-c3ph-focus]'), null, 8000);
137
+ ok('focus: the Open the meeting companion button is found', fbtn);
138
+ if (fbtn) {
139
+ await page.click('[data-c3ph-focus]');
140
+ ok('focus: the compact focus page shows with Back to your day', await waitFor(page, () => !!document.querySelector('[data-c3focus][data-fc-pop] [data-fc="leave"]') && !document.querySelector('[data-c3focus] [data-fc="pop-open"]'), null, 8000));
141
+ await page.click('[data-c3focus] [data-fc="pop-head"] [data-fc="leave"]');
142
+ ok('focus: Back to your day closes it and the companion shows', await waitFor(page, () => !document.querySelector('[data-c3focus]') && !!document.querySelector('[data-c3ph-moment]'), null, 5000));
143
+ }
144
+
134
145
  if (await page.$('[data-c3ph-stop]')) await page.click('[data-c3ph-stop]');
135
146
 
136
147
  await page.close();
@@ -145,19 +156,20 @@ const writes = (p) => (window.__c3demoWrites || []).filter((w) => w.path === p |
145
156
  await page.close();
146
157
  page = await open(pctx, '/c3.html?demo=1&at=10:45');
147
158
 
148
- /* ── full console and back ── */
149
- await page.click('[data-c3ph-desk]');
150
- ok('desk: Full console shows the desktop grid', await waitFor(page, () => { const g = document.querySelector('.c3grid'); return !!g && getComputedStyle(g).display !== 'none' && !document.querySelector('[data-c3ph]'); }, null, 5000));
159
+ /* ── the meeting companion and back ── */
160
+ ok('nodesk: the companion offers no Full console', await page.evaluate(() => !document.querySelector('[data-c3ph-desk]') && !/Full console/.test(document.querySelector('[data-c3ph]').textContent)));
161
+ /* ── a long title and a long list of people stay inside the screen ── */
162
+ ok('narrow: long text does not widen the companion', await page.evaluate(() => {
163
+ const long = 'SC-01 Technical blueprint and estimate: ideal build versus the minimum viable path for the partner programme, MTGA41, Victor, Ho, Jasmine, Cheng, Cara, Chai, Tan, Lee';
164
+ document.querySelectorAll('[data-c3ph] .c3ph-big, [data-c3ph] .c3ph-tt, [data-c3ph] .pmc-title__text, [data-c3ph] .pmc-row__meta > span').forEach((n) => { n.textContent = long; });
165
+ const host = document.querySelector('[data-c3ph]');
166
+ return host.scrollWidth <= innerWidth + 1 && document.documentElement.scrollWidth <= innerWidth + 1;
167
+ }));
168
+
169
+ /* ── a phone that chose Full console before it was dropped shows the companion again ── */
170
+ await page.evaluate(() => localStorage.setItem('pmos.c3.phone', 'desk'));
151
171
  await page.reload({ waitUntil: 'load' });
152
- await waitFor(page, () => !!document.querySelector('nav.c3tabs [role="tab"]'));
153
- ok('desk: the choice survives a reload', await page.evaluate(() => !document.querySelector('[data-c3ph]')));
154
- const more = await page.$('[data-c3pg="more"]');
155
- if (more) {
156
- await more.click();
157
- const item = await page.$('[data-a="phcomp"]');
158
- ok('desk: More offers Phone companion', !!item);
159
- if (item) { await item.click(); ok('desk: Phone companion brings the companion back', await waitFor(page, () => !!document.querySelector('[data-c3ph-moment]'), null, 5000)); }
160
- } else ok('desk: the More button is found', false);
172
+ ok('nodesk: an old Full console choice is cleared and the companion shows', await waitFor(page, () => !!document.querySelector('[data-c3ph-moment]') && !localStorage.getItem('pmos.c3.phone'), null, 8000));
161
173
  await page.close();
162
174
  await pctx.close();
163
175
 
@@ -9,7 +9,7 @@
9
9
  * more Backlog, Releases, Docs and Sprints show only after More; each still opens
10
10
  * calendar one Layers button shows and hides the layer chips
11
11
  * reports no deck is drawn
12
- * phone at 400 wide with Full console chosen, nothing changes: the title shows and no dot is drawn (c3-phone-test.js tests the companion)
12
+ * phone at 400 wide on the desktop layout (lib/c3-full-console.js), nothing changes: the title shows and no dot is drawn (c3-phone-test.js tests the companion)
13
13
  * No script error may be raised.
14
14
  */
15
15
  const path = require('path');
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Jira: tickets drafted from the PRD, and their status"
4
4
  description: "How to draft Jira tickets from a PRD in your ticket template, push the ones that are ready, and see their status, sprint and progress on the initiative's Jira tickets tab."
5
- timestamp: 2026-10-12
5
+ timestamp: 2026-10-13
6
6
  ---
7
7
 
8
8
  # Jira: tickets drafted from the PRD, and their status
@@ -110,6 +110,15 @@ pmos jira query [<initiative>]
110
110
  pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run]
111
111
  ```
112
112
 
113
+ ## The board's Sprints tab shows the whole sprint
114
+
115
+ After it reads the initiatives, the check reads every issue in the sprints their tickets are in. It reads every issue, not only the issues of an initiative and not only the issues assigned to you. The board's **Sprints** tab and the sprint burndown show all of them. An issue that is in no initiative of the board shows **No initiative**. The check writes `<state>/tracker/jira/_sprints.md` with:
116
+
117
+ ```
118
+ pmos jira query --sprints
119
+ pmos jira sync --sprints --from <file|-> [--query "<jql>"]
120
+ ```
121
+
113
122
  ## Link an initiative to its Jira epics
114
123
 
115
124
  Your team may already plan in Jira, with an epic for each piece of work. Then link the initiative to its epics. You do not have to draft or push tickets.
@@ -152,6 +161,8 @@ The morning brief, the day check and requirement gathering use it to tie a Jira
152
161
  - `pmos jira record <initiative> [ID=KEY …] [--epic KEY] [--updated ID,ID]`
153
162
  - `pmos jira query [<initiative>]`
154
163
  - `pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run]`
164
+ - `pmos jira query --sprints`
165
+ - `pmos jira sync --sprints --from <file|-> [--query "<jql>"] [--dry-run]`
155
166
  - `pmos jira which <KEY>`
156
167
 
157
168
  Every command takes `--json` and `--root <dir>`.
@@ -46,7 +46,9 @@ The red button records a conversation or a meeting with the same recorder as **R
46
46
 
47
47
  ## The full console
48
48
 
49
- **Full console** at the top shows the desktop pages on this device, and the device keeps this choice. To go back, open **More** and choose **Phone companion**. In a window wider than 640 pixels the console always shows the desktop pages.
49
+ A phone always shows the companion. There is no way to the desktop pages on a phone: use your computer for them. In a window wider than 640 pixels the console always shows the desktop pages.
50
+
51
+ **Open the meeting companion** opens the meeting helper over the companion. To go back, press **Back to your day** at the top or the bottom. The meeting continues to record.
50
52
 
51
53
  ## Limits
52
54
 
@@ -1,13 +1,13 @@
1
1
  'use strict';
2
2
  /* lib/c3-full-console.js — for browser suites that test the console's desktop layout at phone width.
3
- * Since c3-phone-companion, console3 at 640px or less shows the phone companion. The desktop layout stays reachable there with
4
- * "Full console", which stores pmos.c3.phone = 'desk' on the device. fullConsole(browser) makes every new context start with
5
- * that choice, so a suite that resizes to 400 wide still tests the desktop layout. c3-phone-test.js tests the companion itself. */
3
+ * Since c3-phone-companion, console3 at 640px or less shows the phone companion. The PM has no way to the desktop layout there
4
+ * (Full console was dropped on 2026-10-13); pmos.c3.testDesk = 'desk' keeps it, for suites only. fullConsole(browser) makes every
5
+ * new context start with it, so a suite that resizes to 400 wide still tests the desktop layout. c3-phone-test.js tests the companion itself. */
6
6
  function fullConsole(browser) {
7
7
  const make = browser.newContext.bind(browser);
8
8
  browser.newContext = async (...args) => {
9
9
  const ctx = await make(...args);
10
- await ctx.addInitScript(() => { try { localStorage.setItem('pmos.c3.phone', 'desk'); } catch { /* no storage */ } });
10
+ await ctx.addInitScript(() => { try { localStorage.setItem('pmos.c3.testDesk', 'desk'); } catch { /* no storage */ } });
11
11
  return ctx;
12
12
  };
13
13
  return browser;
@@ -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) → { source, readAt, sprints }
18
+ * sprintsOf(snapshots, onBoard, whole?) → { source, readAt, sprints }
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 }
@@ -523,8 +523,11 @@ function counts(rows, workflow) {
523
523
  * The Jira sprints of a board from the cached status snapshots (lib/jira-status.js readSnapshot), read-only.
524
524
  * snapshots: [{ initiative, fetchedAt, tickets: Map|[] of readings }]; onBoard(initiativeId) → boolean.
525
525
  * A ticket with no sprint (the backlog) is in no sprint. source is 'none' when no snapshot of the board was read.
526
+ * whole: the whole-sprint read (lib/jira-status.js readSprints), { fetchedAt, tickets }. A sprint the board's tickets are in then
527
+ * lists EVERY issue Jira holds in it, whoever it is assigned to; an issue no initiative of the board holds has initiative null
528
+ * (jira-sprint-scope). Without it, a sprint lists only the board's own tickets.
526
529
  */
527
- function sprintsOf(snapshots, onBoard = () => true) {
530
+ function sprintsOf(snapshots, onBoard = () => true, whole = null) {
528
531
  const byName = new Map();
529
532
  let readAt = null;
530
533
  let any = false;
@@ -545,6 +548,28 @@ function sprintsOf(snapshots, onBoard = () => true) {
545
548
  sp.counts[category]++;
546
549
  }
547
550
  }
551
+ const wt = whole && whole.tickets ? (whole.tickets instanceof Map ? [...whole.tickets.values()] : Array.isArray(whole.tickets) ? whole.tickets : []) : [];
552
+ let widened = false;
553
+ for (const sp of byName.values()) {
554
+ const all = wt.filter((t) => t && t.key && t.sprint && String(t.sprint.name) === sp.name);
555
+ if (!all.length) continue;
556
+ widened = true;
557
+ const own = new Map(sp.issues.map((i) => [i.key, i]));
558
+ const keys = new Set(all.map((t) => t.key));
559
+ if (!sp.start && typeof all[0].sprint.start === 'string' && all[0].sprint.start) sp.start = all[0].sprint.start;
560
+ if (!sp.end && typeof all[0].sprint.end === 'string' && all[0].sprint.end) sp.end = all[0].sprint.end;
561
+ sp.issues = [
562
+ ...all.map((t) => {
563
+ const category = ['todo', 'doing', 'done'].includes(t.category) ? t.category : 'todo';
564
+ const o = own.get(t.key);
565
+ 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 };
566
+ }),
567
+ ...sp.issues.filter((i) => !keys.has(i.key)), // a board ticket the sprint read has not seen yet keeps its own reading
568
+ ];
569
+ sp.counts = { todo: 0, doing: 0, done: 0 };
570
+ for (const i of sp.issues) sp.counts[i.category]++;
571
+ }
572
+ if (widened && whole.fetchedAt && (!readAt || whole.fetchedAt < readAt)) readAt = whole.fetchedAt; // the oldest read is the honest age
548
573
  const order = { active: 0, future: 1, closed: 2 };
549
574
  const sprints = [...byName.values()].sort((a, b) => ((order[a.state] ?? 3) - (order[b.state] ?? 3)) || a.name.localeCompare(b.name));
550
575
  for (const sp of sprints) sp.issues.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }));
@@ -10,6 +10,8 @@
10
10
  * pmos jira record <initiative> [ID=KEY …] [--epic KEY] [--updated ID,ID] the keys the agent created
11
11
  * pmos jira query [<initiative>] the Jira search for the tickets an initiative pushed
12
12
  * pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run] a saved search → the status snapshot
13
+ * pmos jira query --sprints the Jira search for every issue in the sprints the tickets are in
14
+ * pmos jira sync --sprints --from <file|-> [--query "<jql>"] a saved whole-sprint search → the Sprints tab (jira-sprint-scope)
13
15
  * pmos jira which <KEY> the initiative a Jira key belongs to
14
16
  * every command: [--json] [--root <dir>]
15
17
  *
@@ -36,11 +38,13 @@ const USAGE = [
36
38
  ' pmos jira record <initiative> [ID=KEY …] [--epic KEY] [--updated ID,ID] the keys created in Jira',
37
39
  ' pmos jira query [<initiative>] the Jira search for the pushed tickets',
38
40
  ' pmos jira sync <initiative> --from <file|-> [--query "<jql>"] [--dry-run] save the status read from Jira',
41
+ ' pmos jira query --sprints the Jira search for every issue in those tickets\' sprints',
42
+ ' pmos jira sync --sprints --from <file|-> [--query "<jql>"] save every issue of the sprints, for the Sprints tab',
39
43
  ' pmos jira which <KEY> the initiative a Jira key belongs to',
40
44
  ' every command: [--json] [--root <dir>]',
41
45
  'This command never calls Jira. Your agent reads and writes Jira with your own connection.',
42
46
  ];
43
- const BOOL = new Set(['json', 'dry-run', 'help']);
47
+ const BOOL = new Set(['json', 'dry-run', 'help', 'sprints']);
44
48
  const VALUE = new Set(['from', 'query', 'root', 'epic', 'updated']);
45
49
  const FLAG_TEXT = { 'prd-changed': 'PRD changed', removed: 'Removed from PRD', blocked: 'Blocked', incomplete: 'Missing text', pushing: 'Selected, waiting for the push', edited: 'Edited, not in Jira yet' };
46
50
 
@@ -92,6 +96,23 @@ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isT
92
96
  const known = () => planItems.INIT_RE.test(initiative || '') && !!jiraTickets.recordFm(root, initiative);
93
97
 
94
98
  try {
99
+ if (flags.sprints && verb !== 'query' && verb !== 'sync') return fail('--sprints goes with query or sync', 2);
100
+ if (verb === 'query' && flags.sprints) {
101
+ 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']);
103
+ }
104
+ if (verb === 'sync' && flags.sprints) {
105
+ if (!flags.from) return need('--sprints --from <file|->');
106
+ let raw;
107
+ try { raw = JSON.parse(readInput(flags.from, cwd, stdin)); }
108
+ catch (e) { return fail(`could not read ${flags.from} as JSON — ${String((e && e.message) || e).slice(0, 160)}`); }
109
+ const r = jiraStatus.syncSprints(root, raw, { query: flags.query || undefined, dryRun: !!flags['dry-run'], now });
110
+ if (!r.ok) return fail(r.error);
111
+ const lines = [`sprints · read ${r.read} ${r.read === 1 ? 'issue' : 'issues'}${r.ignored ? ` · ${r.ignored} in no sprint, ignored` : ''}${r.skipped ? ` · ${r.skipped} skipped (no key or summary)` : ''}${r.dryRun ? ' · dry run, nothing written' : ''}`];
112
+ for (const [name, n] of Object.entries(r.sprints)) lines.push(` ${name} · ${n} ${n === 1 ? 'issue' : 'issues'}`);
113
+ if (!r.dryRun) lines.push(`snapshot ${r.snapshot}`);
114
+ return out(r, lines);
115
+ }
95
116
  if (verb === 'query') {
96
117
  if (initiative) {
97
118
  const q = jiraStatus.queryFor(root, initiative);
@@ -13,6 +13,10 @@
13
13
  * sync(root, initiative, raw, opts) → the snapshot, written to <state>/tracker/jira/<initiative>.md
14
14
  * readSnapshot(root, initiative) → { fetchedAt, query, tickets: Map<key, reading> } | null
15
15
  * which(root, key) → the initiative and ticket a key belongs to
16
+ * sprintQuery(root) → { linked, sprints, query } the JQL for EVERY issue in the sprints the initiatives' tickets
17
+ * are in, whoever holds them (jira-sprint-scope); linked false when no reading has a sprint
18
+ * syncSprints(root, raw, opts) → the whole-sprint snapshot, written to <state>/tracker/jira/_sprints.md
19
+ * readSprints(root) → { fetchedAt, query, tickets: Map<key, reading> } | null
16
20
  *
17
21
  * A READING is { key, summary, status, category: todo|doing|done, sprint: { name, state: active|future|closed, id?, start?, end?,
18
22
  * completed? } | null, subtasks: { done, total }, fixVersion: [names], points: number | null, resolved: YYYY-MM-DD | null,
@@ -206,12 +210,13 @@ function snapshotRel(root, initiative) { return jiraTickets.relOf(root, snapshot
206
210
  const cell = (s) => oneLine(s).replace(/\\/g, '\\\\').replace(/\|/g, '\\|') || '—';
207
211
  const SPRINT_WORD = { active: 'Active sprint', future: 'Next sprint', closed: 'Closed sprint' };
208
212
  function sprintLabel(s) { return s ? `${SPRINT_WORD[s.state] || 'Sprint'}: ${s.name}` : 'Backlog'; }
209
- function renderSnapshot({ initiative, fetchedAt, query, readings, ids }) {
213
+ function renderSnapshot({ initiative, fetchedAt, query, readings, ids, title }) {
214
+ const name = title || initiative;
210
215
  const rows = readings.map((r) => `| ${cell(ids.get(r.key) || '')} | ${cell(r.key)} | ${cell(r.summary)} | ${cell(r.status)} | ${cell(r.category === 'done' ? 'Done' : sprintLabel(r.sprint))} | ${r.subtasks.total ? `${r.subtasks.done} of ${r.subtasks.total}` : '—'} | ${cell(r.flagged ? r.reason : '')} |${r.stale ? ' not in the last read |' : ''}`);
211
216
  return [
212
- '---', 'type: Reference', `title: "Jira status: ${initiative}"`, `timestamp: ${fetchedAt}`, `initiative: ${initiative}`, '---', '',
213
- `# Jira status: ${initiative}`, '',
214
- `Read from Jira at ${fetchedAt} by the Jira status check, read-only. The Jira tickets tab of the initiative shows it. Written by \`pmos jira sync\`; do not edit by hand.`, '',
217
+ '---', 'type: Reference', `title: "Jira status: ${name}"`, `timestamp: ${fetchedAt}`, ...(title ? [] : [`initiative: ${initiative}`]), '---', '',
218
+ `# Jira status: ${name}`, '',
219
+ `Read from Jira at ${fetchedAt} by the Jira status check, read-only. ${title ? 'The Sprints tab and the sprint burndown of a board show it.' : 'The Jira tickets tab of the initiative shows it.'} Written by \`pmos jira sync\`; do not edit by hand.`, '',
215
220
  query ? `Search: \`${query.replace(/`/g, "'")}\`` : 'Search: not recorded', '',
216
221
  '| Ticket | Key | Summary | Status | Sprint | Subtasks | Blocked |', '|---|---|---|---|---|---|---|', ...rows, '',
217
222
  '```json', JSON.stringify({ fetched_at: fetchedAt, query: query || null, tickets: readings }, null, 0), '```', '',
@@ -275,6 +280,48 @@ function sync(root, initiative, raw, { query, dryRun = false, now } = {}) {
275
280
  return { ...result, fetchedAt };
276
281
  }
277
282
 
283
+ // ── the whole sprint (jira-sprint-scope) ─────────────────────────────────────────────────────────────
284
+ // An initiative's read holds only its own tickets, so a sprint drawn from those reads lists only the tickets PMOS knows,
285
+ // not the sprint. This second read asks Jira for every issue in the sprints those tickets are in, whoever holds them.
286
+ const SPRINTS_FILE = '_sprints';
287
+ const jqlName = (n) => `"${String(n).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
288
+ function sprintQuery(root) {
289
+ const seen = new Map();
290
+ for (const q of linkedInitiatives(root).filter((x) => x.linked)) {
291
+ const snap = readSnapshot(root, q.initiative);
292
+ if (!snap) continue;
293
+ for (const t of snap.tickets.values()) {
294
+ const sp = t && t.sprint;
295
+ if (!sp || !sp.name) continue;
296
+ const k = sp.id || sp.name;
297
+ if (!seen.has(k)) seen.set(k, { name: sp.name, state: sp.state || null, ...(sp.id ? { id: sp.id } : {}) });
298
+ }
299
+ }
300
+ 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` };
303
+ }
304
+ /** Save a whole-sprint read. The read replaces the last one whole: an issue moved out of a sprint is gone from it. */
305
+ function syncSprints(root, raw, { query, dryRun = false, now } = {}) {
306
+ const n = normalize(raw);
307
+ if (n.error) return { ok: false, error: n.error };
308
+ const kept = n.issues.filter((i) => i.sprint && i.sprint.name);
309
+ const fetchedAt = nowIso(now);
310
+ const prev = readSprints(root);
311
+ const bySprint = {};
312
+ for (const i of kept) bySprint[i.sprint.name] = (bySprint[i.sprint.name] || 0) + 1;
313
+ const result = { ok: true, read: kept.length, ignored: n.issues.length - kept.length, skipped: n.skipped, sprints: bySprint,
314
+ was: prev ? prev.tickets.size : null, snapshot: snapshotRel(root, SPRINTS_FILE), dryRun: !!dryRun };
315
+ if (dryRun) return result;
316
+ const file = snapshotPath(root, SPRINTS_FILE);
317
+ fs.mkdirSync(path.dirname(file), { recursive: true });
318
+ const tmp = `${file}.${process.pid}.tmp`;
319
+ fs.writeFileSync(tmp, renderSnapshot({ initiative: SPRINTS_FILE, title: 'every issue in the sprints', fetchedAt, query: query || (prev && prev.query) || null, readings: kept, ids: new Map() }));
320
+ fs.renameSync(tmp, file);
321
+ return { ...result, fetchedAt };
322
+ }
323
+ function readSprints(root) { return readSnapshot(root, SPRINTS_FILE); }
324
+
278
325
  // ── J13 · which initiative ─────────────────────────────────────────────────────────────────────────
279
326
  function which(root, key) {
280
327
  const k = String(key || '').trim().toUpperCase();
@@ -288,4 +335,4 @@ function which(root, key) {
288
335
  return { ok: true, key: k, initiatives: hits };
289
336
  }
290
337
 
291
- module.exports = { queryFor, linkedInitiatives, normalize, sync, readSnapshot, which, sprintLabel, snapshotPath, snapshotRel };
338
+ module.exports = { queryFor, linkedInitiatives, normalize, sync, readSnapshot, which, sprintLabel, snapshotPath, snapshotRel, sprintQuery, syncSprints, readSprints, SPRINTS_FILE };
@@ -489,7 +489,9 @@ const READS = {
489
489
  if (s) snaps.push({ initiative: q.initiative, fetchedAt: s.fetchedAt, tickets: s.tickets });
490
490
  }
491
491
  } catch { /* no snapshot yet */ }
492
- ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, ...B.sprintsOf(snaps, (id) => ids.has(id)) });
492
+ let whole = null;
493
+ try { whole = jiraStatus.readSprints(r.w.root); } catch { whole = null; }
494
+ ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, ...B.sprintsOf(snaps, (id) => ids.has(id), whole) });
493
495
  },
494
496
  };
495
497
 
@@ -866,7 +868,9 @@ function meetingBoards(ctx) {
866
868
  if (s) snaps.push({ initiative: q.initiative, fetchedAt: s.fetchedAt, tickets: s.tickets });
867
869
  }
868
870
  } catch { /* no snapshot yet */ }
869
- sprints = B.sprintsOf(snaps, (id) => ids.has(id));
871
+ let whole = null;
872
+ try { whole = jiraStatus.readSprints(w.root); } catch { whole = null; }
873
+ sprints = B.sprintsOf(snaps, (id) => ids.has(id), whole);
870
874
  }
871
875
  return { ...head, health: B.health(rows), counts: { total: c.total, open: c.open, risk: c.risk, late: c.late },
872
876
  rows: rows.filter((r) => r.status === 'late' || r.status === 'at risk').map((r) => ({ id: r.id, title: r.title, status: r.status, end: r.end || null, owner: r.owner || null })),
@@ -122,13 +122,13 @@ function readers(root, runtimeDir) {
122
122
  try { out.own = ownRows().map((o) => ({ id: o.id, title: o.title, due: o.due, done: !!o.done, priority: o.priority || null })); } catch { /* likewise */ }
123
123
  return out;
124
124
  },
125
- /* each initiative's Jira epic progress, from the snapshots in the tracker folder (one file per initiative; the -tickets.md drafts are not snapshots) */
125
+ /* each initiative's Jira epic progress, from the snapshots in the tracker folder (one file per initiative; the -tickets.md drafts and the _sprints.md whole-sprint read are not) */
126
126
  readJira() {
127
127
  const fs = require('fs');
128
128
  const status = require('./jira-status.js');
129
129
  const out = new Map();
130
130
  let names = [];
131
- try { names = fs.readdirSync(require('./jira-tickets.js').jiraDir(root)).filter((n) => n.endsWith('.md') && !n.endsWith('-tickets.md')); } catch { return out; }
131
+ try { names = fs.readdirSync(require('./jira-tickets.js').jiraDir(root)).filter((n) => n.endsWith('.md') && !n.endsWith('-tickets.md') && !n.startsWith('_')); } catch { return out; }
132
132
  for (const n of names) {
133
133
  const id = n.slice(0, -3);
134
134
  try { const p = require('./briefing-real.js').jiraProgress(status.readSnapshot(root, id)); if (p) out.set(id, p); } catch { /* one unreadable snapshot is left out */ }
@@ -164,6 +164,21 @@ function burndownFor(ctx, w, ids, board, sprint) {
164
164
  if (snap.readAt && (!readAt || snap.readAt < readAt)) readAt = snap.readAt; // the oldest read is the honest age
165
165
  }
166
166
  if (!tickets.length) return { ok: true, available: false, reason: 'no Jira read for these initiatives yet' };
167
+ // jira-sprint-scope: a sprint burns down every issue in it, not only the initiatives' own tickets.
168
+ let whole = null;
169
+ try { whole = require('./jira-status.js').readSprints(w.root); } catch { whole = null; }
170
+ if (whole && whole.tickets.size) {
171
+ const names = new Set(tickets.filter((t) => t.sprint && t.sprint.name).map((t) => t.sprint.name));
172
+ const own = new Map(tickets.map((t) => [t.key, t]));
173
+ const add = [...whole.tickets.values()].filter((t) => t.sprint && names.has(t.sprint.name));
174
+ if (add.length) {
175
+ const keys = new Set(add.map((t) => t.key));
176
+ const merged = [...add.map((t) => ({ ...t, initiative: own.has(t.key) ? own.get(t.key).initiative : null })), ...tickets.filter((t) => !keys.has(t.key))];
177
+ tickets.length = 0;
178
+ tickets.push(...merged);
179
+ if (whole.fetchedAt && (!readAt || whole.fetchedAt < readAt)) readAt = whole.fetchedAt;
180
+ }
181
+ }
167
182
  return { ok: true, ...S.burndown(tickets, { sprint: sprint || null }), readAt };
168
183
  }
169
184
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.182.0",
3
+ "version": "0.184.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.182.0",
9
+ "version": "0.184.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.182.0",
3
+ "version": "0.184.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -1,5 +1,5 @@
1
1
  /* pm/c3-focus.js — the Meeting focus page of console3 (initiative c3-meeting-focus; the mockup the PM approved on 2026-10-12, version 7).
2
- * openFocus({ event, date, pop, meet, mtitle }) closeFocus()
2
+ * openFocus({ event, date, pop, back, meet, mtitle }) closeFocus()
3
3
  * One calm full-screen page over console3, inside #c3-root: it covers the header, the tabs and the dock (they stay in the DOM, set inert) and Leave
4
4
  * focus gives them back. Recording is not touched by leaving. It reads GET /api/meeting-focus every 3 seconds while it is open:
5
5
  * header title, time, people, initiative; the clock (starts in, or elapsed and left); REC with Start recording or End meeting (the note dock);
@@ -12,7 +12,8 @@
12
12
  * In demo mode (?demo=1) c3-demo-focus.js answers the reads with the mockup's Partner API review.
13
13
  * Compact mode (?pop=1, initiative c3-meet-extension): the page the Chrome extension frames over a Google Meet call. Only a head (PMOS, the topic or "Starts in",
14
14
  * the time left, a fold button), What's being said, the conversation and a foot (Open focus in PMOS, only you see this). The stage, the bar of the talk, the now line and
15
- * the captured items are not drawn, and there is no Leave focus. ?focus=now first asks GET /api/meeting-focus/now (meet = the Meet code, mtitle = the tab's title) which
15
+ * the captured items are not drawn, and there is no Leave focus. With back (the phone companion opens it so) the head and the foot have
16
+ * "Back to your day" in place of Open focus in PMOS, which closes the page as Leave focus does. ?focus=now first asks GET /api/meeting-focus/now (meet = the Meet code, mtitle = the tab's title) which
16
17
  * meeting the call is; when none fits, the page says so and lists today's meetings to pick from. Fold shows the head only and tells the frame's parent with one
17
18
  * message, { pmosFocus: 'fold' | 'open' }, that carries no data. */
18
19
  import { el } from './dom.js';
@@ -80,7 +81,7 @@ export function closeFocus() {
80
81
  if (f.opener && document.contains(f.opener) && typeof f.opener.focus === 'function') { try { f.opener.focus({ preventScroll: true }); } catch { /* not focusable any more */ } }
81
82
  }
82
83
 
83
- export function openFocus({ event, date, pop, meet, mtitle } = {}) {
84
+ export function openFocus({ event, date, pop, back, meet, mtitle } = {}) {
84
85
  closeFocus();
85
86
  const ev = event || {};
86
87
  const want = String(ev.id || '');
@@ -102,7 +103,8 @@ export function openFocus({ event, date, pop, meet, mtitle } = {}) {
102
103
  el('div', { class: 'fc-popbody', 'data-fc': 'pop-body' },
103
104
  r.band = el('div', { class: 'fc-bandwrap' }), convo,
104
105
  r.foot = el('div', { class: 'fc-foot', 'data-fc': 'pop-foot' },
105
- lk({ 'data-fc': 'pop-open', title: 'Opens the full focus page in a new tab' }, icon('out', 'sm'), 'Open focus in PMOS'), el('span', { class: 'dim mono' }, 'only you see this'))))
106
+ back ? pbtn('ghost', 'Back to your day', 'prev', { 'data-fc': 'leave', title: 'Back to the companion. The meeting keeps recording.' })
107
+ : lk({ 'data-fc': 'pop-open', title: 'Opens the full focus page in a new tab' }, icon('out', 'sm'), 'Open focus in PMOS'), el('span', { class: 'dim mono' }, 'only you see this'))))
106
108
  : el('div', { class: 'fc-page' },
107
109
  r.top = el('header', { class: 'card fc-top', 'aria-label': 'Meeting' }),
108
110
  r.now = el('div', { class: 'fc-nowl', 'data-fc': 'now-line' }),
@@ -112,7 +114,7 @@ export function openFocus({ event, date, pop, meet, mtitle } = {}) {
112
114
  el('aside', { class: 'fc-w', 'aria-label': 'Conversation' }, convo)));
113
115
  root.append(page);
114
116
  F = { id, date: day, ev0: ev, root, r, sig: {}, data: null, offset: 0, ui: { open: new Set(), folded: new Set(), pulled: [], stage: 0, picker: false, newTopic: false, addTopic: false, heldOpen: false, pending: new Map(), failed: new Map(), pill: false },
115
- pop: !!pop, meet: String(meet || ''), mtitle: String(mtitle || ''), choices: null,
117
+ pop: !!pop, back: !!back, meet: String(meet || ''), mtitle: String(mtitle || ''), choices: null,
116
118
  timers: {}, askTimers: new Map(), sheet: null, sheetOpen: false, status: 'reading', error: '',
117
119
  opener: document.activeElement && document.activeElement !== document.body ? document.activeElement : null, seenNotes: 0 };
118
120
  // a fixed box inside an element with container-type is placed against that element, not the screen: lift it while the page is open
@@ -232,7 +234,7 @@ function drawPopHead(d, ph) {
232
234
  : ph === 'after' ? (e.title || 'Meeting') + ' has ended' : cur ? cur.text : e.title;
233
235
  const left = d && ph === 'during' && Number.isFinite(f) ? el('span', { class: 'mono dim', 'data-fc': 'left', title: 'Time left' }, clockText((f - nowMs()) / 1000)) : null;
234
236
  const lbl = F.ui.pill ? 'Open' : 'Fold to a pill';
235
- return [el('b', { class: 'mono' }, 'PMOS'), el('span', { class: 'fc-ph-topic', 'data-fc': 'pop-topic', title: typeof label === 'string' ? label : '' }, label), left,
237
+ return [F.back ? ib('Back to your day', 'prev', { 'data-fc': 'leave' }) : null, el('b', { class: 'mono' }, 'PMOS'), el('span', { class: 'fc-ph-topic', 'data-fc': 'pop-topic', title: typeof label === 'string' ? label : '' }, label), left,
236
238
  ib(lbl, F.ui.pill ? 'next' : 'fold', { 'data-fc': 'pill', 'aria-pressed': String(F.ui.pill) })];
237
239
  });
238
240
  }
@@ -4,10 +4,14 @@
4
4
  * Everything is scoped under .c3 and uses tokens only. */
5
5
  .c3.c3-phone-on .c3grid{display:none !important}
6
6
  .c3.c3-phone-on{min-height:100dvh}
7
- .c3 .c3ph{display:flex;flex-direction:column;min-height:100dvh;background:var(--background)}
7
+ .c3 .c3ph{display:flex;flex-direction:column;min-height:100dvh;width:100%;max-width:100vw;min-width:0;overflow-x:hidden;background:var(--background)}
8
+ /* a long title or a long list of people wraps or is cut with an ellipsis; it never widens the companion past the screen */
9
+ .c3 .c3ph *{min-width:0}
10
+ .c3 .c3ph-body,.c3 .c3ph-card,.c3 .c3ph-later,.c3 .c3ph-fit,.c3 .c3ph-sheetbody{grid-template-columns:minmax(0,1fr)}
11
+ .c3 .c3ph-big,.c3 .c3ph-tt,.c3 .c3ph-line,.c3 .c3ph-hd b{overflow-wrap:anywhere}
8
12
  .c3 .c3ph-hd{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:calc(10px + env(safe-area-inset-top)) 16px 10px;position:sticky;top:0;background:var(--background);z-index:2}
9
- .c3 .c3ph-hd b{font-size:var(--fs-4)}
10
- .c3 .c3ph-hdr{display:flex;align-items:center;gap:8px}
13
+ .c3 .c3ph-hd b{font-size:var(--fs-4);flex:1;min-width:0}
14
+ .c3 .c3ph-hdr{display:flex;align-items:center;gap:8px;flex:none}
11
15
  .c3 .c3ph-dotn{display:inline-flex;align-items:center;gap:4px;font:var(--fs-1) var(--mono);color:var(--muted-foreground)}
12
16
  .c3 .c3ph-body{flex:1;display:grid;gap:12px;align-content:start;padding:0 12px 16px}
13
17
  .c3 .c3ph-card{display:grid;gap:8px;padding:14px}
@@ -20,13 +24,13 @@
20
24
  .c3 .c3ph-rec-why{font-size:var(--fs-2);line-height:1.45;background:var(--muted);border-radius:8px;padding:8px 10px}
21
25
  .c3 .c3ph-prep{display:flex;gap:6px;align-items:flex-start;font-size:var(--fs-2);line-height:1.45}
22
26
  .c3 .c3ph-before,.c3 .c3ph-fit{display:grid;gap:6px;padding-top:8px;border-top:1px solid var(--border);font-size:var(--fs-2)}
23
- .c3 .c3ph-before{grid-template-columns:1fr auto;align-items:center}
27
+ .c3 .c3ph-before{grid-template-columns:minmax(0,1fr) auto;align-items:center}
24
28
  .c3 .c3ph-acts{display:grid;gap:8px}
25
29
  .c3 .c3ph-acts.two{grid-template-columns:1fr 1fr}
26
30
  .c3 .c3ph-b{min-height:44px;justify-content:center}
27
31
  .c3 .c3ph-cnt{display:flex;justify-content:space-between;font-weight:600;font-size:var(--fs-2)}
28
32
  .c3 .c3ph-cnt .dim{font-weight:400;font-family:var(--mono)}
29
- .c3 .c3ph-later{display:grid;gap:4px;font-size:var(--fs-2);color:var(--muted-foreground);padding:0 4px}
33
+ .c3 .c3ph-later{display:grid;overflow:hidden;gap:4px;font-size:var(--fs-2);color:var(--muted-foreground);padding:0 4px}
30
34
  .c3 .c3ph-ft{position:sticky;bottom:0;display:flex;align-items:center;gap:10px;padding:8px 12px calc(12px + env(safe-area-inset-bottom));border-top:1px solid var(--border);background:var(--card);z-index:2}
31
35
  .c3 .c3ph-ask{flex:1;display:flex;align-items:center;gap:8px;border:1px solid var(--border);border-radius:8px;padding:0 4px 0 12px;min-height:44px;color:var(--muted-foreground);background:var(--background)}
32
36
  .c3 .c3ph-ask input.c3ph-in{flex:1;min-width:0;border:0;background:transparent;font-family:inherit;font-size:var(--fs-3);color:var(--foreground);padding:10px 0;outline:none;border-radius:0}
@@ -18,7 +18,7 @@
18
18
  * points to the keyboard's own dictation; this page does not send audio anywhere.
19
19
  * Record the red button records a conversation or a meeting, the same recorder as the desktop Record control (note-dock.js).
20
20
  * While it records, the bar shows the clock and Stop. In demo mode it drives the simulated recorder and saves nothing.
21
- * "Full console" switches this device to the desktop layout (kept on the device); the desktop More menu has "Phone companion" to come back.
21
+ * There is no way to the desktop pages on a phone. The meeting companion opens as the compact focus page with "Back to your day".
22
22
  * The moment is worked out again every minute; when it changes, a short banner says so. ?at=HH:MM sets the clock in demo mode only. */
23
23
  import { el } from './dom.js';
24
24
  import { button, iconButton } from './primitives.js';
@@ -46,10 +46,11 @@ function ensureCss() {
46
46
  document.head.appendChild(el('link', { id: CSS_ID, rel: 'stylesheet', href: '/pm/c3-phone.css' }));
47
47
  }
48
48
 
49
- /** Is this device in phone mode? Phone width, and the PM has not chosen the full console on it. */
49
+ /** Is this device in phone mode? Phone width: there is no choice to show the desktop pages on a phone (Wawan, 2026-10-13).
50
+ * pmos.c3.testDesk is for the browser suites that test the desktop layout at phone width (lib/c3-full-console.js); nothing offers it. */
50
51
  export function phoneWanted(store) {
51
52
  if (typeof matchMedia !== 'function' || !matchMedia('(max-width:640px)').matches) return false;
52
- return !(store && store.get('pmos.c3.phone') === 'desk');
53
+ return !(store && store.get('pmos.c3.testDesk') === 'desk');
53
54
  }
54
55
 
55
56
  export function mountPhone(root, ctx = {}) {
@@ -194,7 +195,7 @@ export function mountPhone(root, ctx = {}) {
194
195
  }
195
196
  const decisions = () => waitRows().filter((r) => !r.pm).length;
196
197
  const needOpts = { onDone: (r) => itemDone(r), onLater: (r) => itemLater(r, 'later-today') };
197
- const focus = (m) => import('./c3-focus.js').then((f) => f.openFocus({ event: { id: m.id, start: m.start }, pop: true })).catch(() => toast('The meeting companion did not open.'));
198
+ const focus = (m) => import('./c3-focus.js').then((f) => f.openFocus({ event: { id: m.id, start: m.start }, pop: true, back: true })).catch(() => toast('The meeting companion did not open.'));
198
199
  /* a meeting's sheet (c3-phone-detail.js): Record it while the meeting has not ended, and the meeting companion */
199
200
  function meetingSheet(m) {
200
201
  const t = now().getTime(); const ends = Date.parse(m.end || m.start) || 0;
@@ -311,8 +312,7 @@ export function mountPhone(root, ctx = {}) {
311
312
  const d = now();
312
313
  const n = waitRows().length;
313
314
  head.replaceChildren(el('b', null, WD[d.getDay()] + ' ' + d.getDate() + ' ' + MO[d.getMonth()]),
314
- el('span', { class: 'c3ph-hdr' }, n ? el('span', { class: 'c3ph-dotn', 'data-c3ph-count': String(n) }, c3ic('inbox', 'sm'), n + ' waiting') : null,
315
- button({ label: 'Full console', variant: 'ghost', size: 'sm', attrs: { 'data-c3ph-desk': '' }, onClick: () => ctx.onDesk && ctx.onDesk() })));
315
+ el('span', { class: 'c3ph-hdr' }, n ? el('span', { class: 'c3ph-dotn', 'data-c3ph-count': String(n) }, c3ic('inbox', 'sm'), n + ' waiting') : null));
316
316
  }
317
317
 
318
318
  const BANNER = { meeting: (m) => m.title + ' has started', before: (m, mo) => m.title + ' starts ' + inText(mo.minutes), after: (m) => m.title + ' just ended', gap: (m, mo) => (mo.minutes || 0) + ' free minutes', wrap: () => 'Time to wrap up', morning: () => 'Good morning' };
@@ -138,7 +138,6 @@ function toggleMore() {
138
138
  item('alert', 'Incident day', () => toggleIncident(), S.incident, S.demo ? { 'data-a': 'inc' } : { 'data-a': 'inc', disabled: true, title: 'No incident feed is connected, so this page shows no incident.' }),
139
139
  item('spark', 'Agent playbooks', () => openPlaybooks(), false, { 'data-a': 'pbopen' }),
140
140
  item('phone', 'Phone preview', () => togglePreview(), !!S.preview, { 'data-a': 'phone' }),
141
- S.root.classList.contains('ph') && store.get('pmos.c3.phone') === 'desk' ? item('phone', 'Phone companion', () => { store.set('pmos.c3.phone', ''); syncPhone(); }, false, { 'data-a': 'phcomp' }) : null,
142
141
  item(S.theme === 'dark' ? 'sun' : 'moon', S.theme === 'dark' ? 'Light theme' : 'Dark theme', () => setTheme(S.theme === 'dark' ? 'light' : 'dark')),
143
142
  ((b) => { b.firstChild.replaceWith(gearIcon('sm')); return b; })(item('info', 'Settings', () => showTab('settings', true), false, { 'data-a': 'settings' })),
144
143
  item('term', S.dockOpen ? 'Fold the dock' : 'Open the dock', () => { S.dockOpen = !S.dockOpen; keepDock(); syncDock(); }, false, { 'data-a': 'dockt' }, 'Ctrl J'),
@@ -151,13 +150,14 @@ function toggleMore() {
151
150
  document.addEventListener('keydown', S.menuOff); document.addEventListener('pointerdown', S.menuOff);
152
151
  const first = S.menu.querySelector('.c3pg-mi:not([disabled])'); if (first) first.focus();
153
152
  }
154
- /* The phone companion (c3-phone.js): at 640px or less, unless the PM chose the full console on this device, it fills the page and the
155
- * desktop grid is hidden. "Full console" stores that choice; the More menu's "Phone companion" clears it. */
153
+ /* The phone companion (c3-phone.js): at 640px or less it fills the page and the desktop grid is hidden. There is no Full console:
154
+ * the old choice (pmos.c3.phone = 'desk', stored before 2026-10-13) is cleared so no phone stays on the desktop pages. */
156
155
  function syncPhone() {
156
+ if (store.get('pmos.c3.phone')) store.set('pmos.c3.phone', '');
157
157
  const want = phoneWanted(store);
158
158
  if (want && !S.phone) {
159
159
  S.root.classList.add('c3-phone-on');
160
- S.phone = mountPhone(S.root, { ws: S.ws, demo: S.demo, onDesk: () => { store.set('pmos.c3.phone', 'desk'); syncPhone(); } });
160
+ S.phone = mountPhone(S.root, { ws: S.ws, demo: S.demo });
161
161
  } else if (!want && S.phone) {
162
162
  S.phone.dispose(); S.phone = null;
163
163
  S.root.classList.remove('c3-phone-on');
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: jira-sync
3
3
  label: "Jira status check"
4
- description: "Read, read-only, the status, sprint and subtasks of the Jira tickets each initiative pushed, or of its Jira epics and the issues under them, and save them in PMOS for the initiative's Jira tickets tab. It writes nothing to Jira."
4
+ description: "Read, read-only, the status, sprint and subtasks of the Jira tickets each initiative pushed, or of its Jira epics and the issues under them, and every issue in the sprints those tickets are in, and save them in PMOS for the initiative's Jira tickets tab and the board's Sprints tab. It writes nothing to Jira."
5
5
  run: agent
6
6
  agent: claude
7
7
  output:
@@ -35,10 +35,11 @@ Procedure:
35
35
  - `url`: the issue's browse link, `https://<site>.atlassian.net/browse/<KEY>`.
36
36
  The shape Jira returned (`{"issues": [{"key", "self", "fields": {...}}]}`) is also accepted.
37
37
  4. Run `pmos jira sync <initiative> --from <the file> --query "<the JQL>"`. Then delete the file.
38
+ 4a. Read the whole sprints, after every initiative is synced. Run `pmos jira query --sprints`. It prints one Jira search for every issue in the sprints the tickets above are in, whoever the issue is assigned to. Do this even when the task names one initiative. If it prints "no Jira reading has a sprint yet", skip this step. Otherwise run that search with the same fields as step 2, fetch every page, save the result in the scratch folder in the same shape as step 3 (for example `<scratch>/jira-sprints.json`), run `pmos jira sync --sprints --from <the file> --query "<the JQL>"`, and delete the file. Do not record people from this search (step 5 is for the initiatives' own issues).
38
39
  5. Record the people in People. For each assignee and reporter other than the PM, write one sighting per issue (People adds each new person at once): {"source": "jira", "handle": {"kind": "jira", "value": "<their Jira display name>"}, "name": "<display name>", "interaction": {"date": "<the issue's last update, YYYY-MM-DD>", "kind": "Jira", "text": "<Assignee|Reporter> of <KEY>: <summary, at most 150 characters>", "pointer": "jira · <KEY> · <url>", "initiative": "<the initiative id>"}}. Put all of them in one file {"sightings": [...]} in the scratch folder, run `pmos people record --file <that file>` once, and delete the file. An issue with no assignee gives no assignee sighting.
39
40
  6. Finish with the three sections of the output contract, one short line each:
40
- - Checked: the initiative, the number of tickets read, and each change (status, sprint, blocked). If nothing changed, say "no change". Add how many people you recorded.
41
- - Not reached: each initiative whose search failed, with the reason.
41
+ - Checked: the initiative, the number of tickets read, and each change (status, sprint, blocked). If nothing changed, say "no change". Add how many people you recorded, and how many issues the sprint read saved per sprint.
42
+ - Not reached: each initiative whose search failed, with the reason, and the sprint read if it failed.
42
43
  - Needs you: tickets newly blocked, tickets the search did not return, and any refusal.
43
44
 
44
45
  Output contract — produce EXACTLY these sections, the same shape every run: