create-pmos 0.175.0 → 0.177.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.175.0",
3
+ "version": "0.177.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@128e902
1
+ kernel-source: pmos@9ea3e51
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-13T01:08:07Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T01:28:03Z"}
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-13T01:08:07Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T01:28:03Z"}
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-13T01:08:07Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-13T01:28:03Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -43,6 +43,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
43
43
  srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
44
44
  await T.waitForServer(BASE);
45
45
  browser = await chromium.launch({ executablePath: exe });
46
+ require('./lib/c3-full-console').fullConsole(browser); // the desktop layout at phone width (c3-phone-companion)
46
47
 
47
48
  const open = async (ctxOpts) => {
48
49
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 }, ...(ctxOpts || {}) });
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /* c3-phone-test.js — the phone companion (initiative c3-phone-companion; PRD done-criteria 1 to 6 and 8).
4
+ * A real server and a real Chromium drive /c3.html in demo mode (clock frozen at Thu 8 Oct 2026 08:12; ?at=HH:MM moves the companion's clock).
5
+ *
6
+ * phone at 400 wide the companion shows and the desktop grid does not
7
+ * moments 08:12 morning; 10:10 before Checkout sync; 10:45 in the meeting with the Record offer
8
+ * done Done on the waiting item records a write to /api/pm-items/done
9
+ * tell text sent from the ask bar records a capture and clears the bar
10
+ * record the red button shows the recording line; Stop brings the bar back
11
+ * debrief 11:25 shows the decisions and actions of the recorded Checkout sync; a meeting with no recording asks you to tell PMOS
12
+ * desk Full console shows the desktop grid and survives a reload; More > Phone companion brings it back
13
+ * desktop at 1280 wide no companion is drawn
14
+ * No script error may be raised.
15
+ */
16
+ const path = require('path');
17
+ const { spawn } = require('child_process');
18
+ const { findBrowser } = require('./lib/find-browser');
19
+ const T = require('./test-run').configure({ name: 'c3-phone' });
20
+
21
+ const BASE = T.BASE;
22
+ const results = [];
23
+ const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + String(extra).slice(0, 400) : ''}`); };
24
+ let srv = null; let browser = null;
25
+ const finish = async (code) => {
26
+ if (browser) await browser.close().catch(() => {});
27
+ if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
28
+ T.killTmux();
29
+ const bad = results.filter((x) => !x.cond).length;
30
+ console.log(`\n${results.length - bad}/${results.length} c3-phone assertions passed`);
31
+ process.exit(code !== undefined ? code : (bad ? 1 : 0));
32
+ };
33
+ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
34
+
35
+ async function waitFor(page, fn, arg, ms = 30000) {
36
+ try { await page.waitForFunction(fn, arg, { timeout: ms }); return true; } catch { return false; }
37
+ }
38
+ const shown = (s) => { const n = document.querySelector(s); return !!n && getComputedStyle(n).display !== 'none' && n.getBoundingClientRect().height > 0; };
39
+ const writes = (p) => (window.__c3demoWrites || []).filter((w) => w.path === p || w.url === p || String(w.path || w.url || '').startsWith(p)).length;
40
+
41
+ (async () => {
42
+ const exe = findBrowser();
43
+ if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the c3-phone suite cannot run'); return finish(1); }
44
+ let chromium;
45
+ try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed — the c3-phone suite cannot run'); return finish(1); }
46
+
47
+ srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
48
+ await T.waitForServer(BASE);
49
+ browser = await chromium.launch({ executablePath: exe });
50
+ const errors = [];
51
+ const ctxOf = async (w) => browser.newContext({ viewport: { width: w, height: 860 } });
52
+ const open = async (ctx, url, ready) => {
53
+ const page = await ctx.newPage();
54
+ page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
55
+ await page.goto(BASE + url, { waitUntil: 'load' });
56
+ await waitFor(page, ready || (() => !!document.querySelector('[data-c3ph-moment]')));
57
+ return page;
58
+ };
59
+ const moment = (page) => page.evaluate(() => document.querySelector('[data-c3ph]')?.dataset.moment || '');
60
+
61
+ /* ── phone, morning ── */
62
+ const pctx = await ctxOf(400);
63
+ let page = await open(pctx, '/c3.html?demo=1');
64
+ ok('phone: the companion shows at 400 wide', await page.evaluate(shown, '[data-c3ph]'));
65
+ ok('phone: the desktop grid is hidden', !(await page.evaluate(shown, '.c3grid')));
66
+ ok('phone: no sideways scroll', await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
67
+ ok('moments: 08:12 is morning', (await moment(page)) === 'morning', await moment(page));
68
+ ok('phone: the ask bar and the red Record button are in the bottom bar', await page.evaluate(() => !!document.querySelector('[data-c3ph-foot] [data-c3ph-input]') && !!document.querySelector('[data-c3ph-foot] [data-c3ph-record]')));
69
+
70
+ /* ── done on the waiting item ── */
71
+ const hasWaiting = await waitFor(page, () => !!document.querySelector('[data-c3ph-waiting] [data-c3ph-done]'), null, 10000);
72
+ ok('done: a waiting card with Done shows', hasWaiting);
73
+ if (hasWaiting) {
74
+ const before = await page.evaluate(writes, '/api/pm-items/done');
75
+ await page.click('[data-c3ph-waiting] [data-c3ph-done]');
76
+ ok('done: Done records a write to /api/pm-items/done', await waitFor(page, (n) => ((window.__c3demoWrites || []).filter((w) => String(w.path || w.url || '').startsWith('/api/pm-items/done')).length) > n, before, 5000));
77
+ }
78
+
79
+ /* ── tell PMOS ── */
80
+ await page.fill('[data-c3ph-input]', 'Ask Maya about the beta date');
81
+ await page.press('[data-c3ph-input]', 'Enter');
82
+ ok('tell: the text is filed as a capture', await waitFor(page, () => (window.__c3demoWrites || []).some((w) => w.path === '/api/capture' && w.body && /beta date/.test(w.body.text)), null, 5000));
83
+ ok('tell: the bar is cleared', (await page.inputValue('[data-c3ph-input]')) === '');
84
+
85
+ /* ── record ── */
86
+ await page.click('[data-c3ph-record]');
87
+ ok('record: the recording line shows with Stop', await waitFor(page, () => !!document.querySelector('[data-c3ph-recbar] [data-c3ph-stop]'), null, 8000));
88
+ ok('record: the ask bar gives way while recording', !(await page.evaluate(() => !!document.querySelector('[data-c3ph-foot] [data-c3ph-input]'))));
89
+ if (await page.$('[data-c3ph-stop]')) await page.click('[data-c3ph-stop]');
90
+ ok('record: Stop brings the bar back', await waitFor(page, () => !!document.querySelector('[data-c3ph-foot] [data-c3ph-input]') && !document.querySelector('[data-c3ph-recbar]'), null, 8000));
91
+ await page.close();
92
+
93
+ /* ── moments by the clock ── */
94
+ page = await open(pctx, '/c3.html?demo=1&at=10:10');
95
+ ok('moments: 10:10 is before Checkout sync', (await moment(page)) === 'before' && /Checkout sync/.test(await page.textContent('[data-c3ph-moment]')), await moment(page));
96
+ await page.close();
97
+ page = await open(pctx, '/c3.html?demo=1&at=10:45');
98
+ ok('moments: 10:45 is in the meeting', (await moment(page)) === 'meeting', await moment(page));
99
+ ok('moments: the meeting card offers to record it', await page.evaluate(() => !!document.querySelector('[data-c3ph-moment] [data-c3ph-rec-meeting]') && !!document.querySelector('[data-c3ph-moment] [data-c3ph-norec]')));
100
+ ok('moments: button icons in the moment card stay small', await page.evaluate(() => [...document.querySelectorAll('[data-c3ph-moment] .pmc-btn svg')].every((s) => s.getBoundingClientRect().width <= 20)));
101
+ await page.click('[data-c3ph-rec-meeting]');
102
+ ok('moments: Record starts recording Checkout sync', await waitFor(page, () => /Checkout sync/.test(document.querySelector('[data-c3ph-recbar]')?.textContent || ''), null, 8000));
103
+ ok('moments: while recording, the card opens the meeting companion', await waitFor(page, () => !!document.querySelector('[data-c3ph-moment] [data-c3ph-focus]'), null, 5000));
104
+ if (await page.$('[data-c3ph-stop]')) await page.click('[data-c3ph-stop]');
105
+
106
+ await page.close();
107
+
108
+ /* ── just ended: the debrief from the recording's notes, as on the desktop ── */
109
+ page = await open(pctx, '/c3.html?demo=1&at=11:25');
110
+ ok('debrief: 11:25 is just after Checkout sync', (await moment(page)) === 'after', await moment(page));
111
+ ok('debrief: the card shows the decisions and actions from the recording', await waitFor(page, () => { const c = document.querySelector('[data-c3ph-moment="after"]'); return c && c.dataset.c3phDebriefState === 'ready' && !!c.querySelector('[data-c3ph-debrief="decisions"]') && !!c.querySelector('[data-c3ph-debrief="actions"]') && /31 Oct/.test(c.textContent); }, null, 8000));
112
+ await page.close();
113
+ page = await open(pctx, '/c3.html?demo=1&at=17:10');
114
+ ok('debrief: a meeting with no recording asks you to tell PMOS', await waitFor(page, () => { const c = document.querySelector('[data-c3ph-moment="after"]'); return c && c.dataset.c3phDebriefState === 'none' && !!c.querySelector('[data-c3ph-tellafter]'); }, null, 8000));
115
+ await page.close();
116
+ page = await open(pctx, '/c3.html?demo=1&at=10:45');
117
+
118
+ /* ── full console and back ── */
119
+ await page.click('[data-c3ph-desk]');
120
+ 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));
121
+ await page.reload({ waitUntil: 'load' });
122
+ await waitFor(page, () => !!document.querySelector('nav.c3tabs [role="tab"]'));
123
+ ok('desk: the choice survives a reload', await page.evaluate(() => !document.querySelector('[data-c3ph]')));
124
+ const more = await page.$('[data-c3pg="more"]');
125
+ if (more) {
126
+ await more.click();
127
+ const item = await page.$('[data-a="phcomp"]');
128
+ ok('desk: More offers Phone companion', !!item);
129
+ if (item) { await item.click(); ok('desk: Phone companion brings the companion back', await waitFor(page, () => !!document.querySelector('[data-c3ph-moment]'), null, 5000)); }
130
+ } else ok('desk: the More button is found', false);
131
+ await page.close();
132
+ await pctx.close();
133
+
134
+ /* ── desktop ── */
135
+ const dctx = await ctxOf(1280);
136
+ page = await open(dctx, '/c3.html?demo=1', () => !!document.querySelector('nav.c3tabs [role="tab"]'));
137
+ ok('desktop: no companion at 1280 wide', await page.evaluate(() => !document.querySelector('[data-c3ph]') && !document.querySelector('.c3.c3-phone-on')));
138
+ ok('desktop: the grid shows', await page.evaluate(shown, '.c3grid'));
139
+ await dctx.close();
140
+
141
+ ok('no script error', errors.length === 0, errors.join(' | '));
142
+ finish();
143
+ })();
@@ -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 nothing changes: the title shows and no dot is drawn (the phone gets its own companion design)
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)
13
13
  * No script error may be raised.
14
14
  */
15
15
  const path = require('path');
@@ -45,6 +45,7 @@ const shown = (s) => { const n = document.querySelector(s); return !!n && getCom
45
45
  srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
46
46
  await T.waitForServer(BASE);
47
47
  browser = await chromium.launch({ executablePath: exe });
48
+ require('./lib/c3-full-console').fullConsole(browser); // the desktop layout at phone width (c3-phone-companion)
48
49
  const errors = [];
49
50
  const open = async (w, url) => {
50
51
  const ctx = await browser.newContext({ viewport: { width: w, height: 860 } });
@@ -91,6 +91,7 @@ const DEF_P = ['north', 'outcomes', 'signals', 'delivery', 'bets', 'synergy', 'm
91
91
  srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
92
92
  await T.waitForServer(BASE);
93
93
  browser = await chromium.launch({ executablePath: exe });
94
+ require('./lib/c3-full-console').fullConsole(browser); // the desktop layout at phone width (c3-phone-companion)
94
95
 
95
96
  const errors = [];
96
97
  const watch = (page) => {
@@ -177,6 +177,8 @@ function startShell(name) {
177
177
  /* ── D: End asks first, then ends ── */
178
178
  await page.click('[data-c3pg="agent-button"]');
179
179
  ok('D0 the panel opens again', await waitFor(page, (s) => !!document.querySelector(s), sel));
180
+ 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)
181
+ && await page.evaluate((i) => localStorage.getItem('pmos.workspace.c3.last-session') === i, id));
180
182
  if (!(await page.isVisible(`[data-end-session="${id}"]`).catch(() => false))) await page.click(sel + ' .nb');
181
183
  await page.click(`[data-end-session="${id}"]`);
182
184
  ok('D1 End asks first', await waitFor(page, (i) => !!document.querySelector(`[data-end-confirm="${i}"]`), id));
@@ -60,6 +60,7 @@ const W = '[data-pm-work]';
60
60
  srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
61
61
  await T.waitForServer(BASE);
62
62
  browser = await chromium.launch({ executablePath: exe });
63
+ require('./lib/c3-full-console').fullConsole(browser); // the desktop layout at phone width (c3-phone-companion)
63
64
  const ctx = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
64
65
  const page = await ctx.newPage();
65
66
  const errors = [];
@@ -9,6 +9,8 @@ timestamp: 2026-10-13
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
+ 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.
13
+
12
14
  ## The dock
13
15
 
14
16
  The dock is folded when you first open the console. To open it, press Ctrl J (⌘J on a Mac), or use **Open the dock** in the **More** menu. The browser keeps your choice: when you leave the dock open, it opens on the Briefing next time. On the other tabs it is folded away, and no rail of buttons is left at the right edge.
@@ -48,6 +48,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
48
48
  - [People](people.md): the people you work with in a repository, how they get there, and `pmos people`.
49
49
  - [Docs](docs.md): whether each linked document is the same as, ahead of or behind its Google Docs and Confluence copy.
50
50
  - [Calendar popup](calendar-popup.md): open the full calendar over any page of the console with the calendar button or the C key, and close it.
51
+ - [The phone companion](phone-companion.md): what the console shows at phone width: one moment of your day, your items one at a time, Tell PMOS and Record.
51
52
  - [Layout](layout.md): the frame, the keys and the rule for where a list or a detail opens.
52
53
  - [The Calendar tab of the console page](console-calendar.md): the zooms, the layer chips, the markers, the context of a meeting and free time.
53
54
  - [Statuses](statuses.md): what each status means, where it comes from, and what you can change.
@@ -0,0 +1,47 @@
1
+ ---
2
+ type: Guide
3
+ title: "Guide: the phone companion"
4
+ description: "At phone width the console shows a companion: one moment of your day, your items one at a time, Tell PMOS and Record."
5
+ timestamp: 2026-10-13
6
+ ---
7
+
8
+ # Guide: the phone companion
9
+
10
+ When the console (`/`) is 600 pixels wide or less, for example on a phone, it shows the companion and not the desktop pages. The companion is not a small copy of the desktop. It shows the one thing that is useful now, and it keeps decisions for your computer.
11
+
12
+ ## The moment card
13
+
14
+ The top card shows where you are in your day and why it is useful now. PMOS chooses the moment from today's meetings and the clock:
15
+
16
+ | Moment | When | What the card shows |
17
+ |---|---|---|
18
+ | Your day | Before noon, with no meeting in the next 30 minutes | The next meeting, the number of your open items and the number of decisions that wait for your computer |
19
+ | Before you walk in | The next meeting starts in 30 minutes or less | What is prepared for it, and the items to close first, each with **Done** |
20
+ | In the meeting | A meeting is in progress | **Record this meeting?** with **Record** and **Not this one**. When it records, **Open the meeting companion** opens the meeting focus page. |
21
+ | Just ended | A meeting ended less than 20 minutes ago | **What came out of it?** If you recorded the meeting, the card shows the summary, the decisions, the actions and the open questions from its notes, the same notes as on the Notes page. While PMOS writes the notes, the card says so. If you did not record it, **Tell PMOS** puts the meeting name in the bar so you can type the result. |
22
+ | Free time | 15 minutes or more before the next meeting | The items that fit in the time: overdue items first, then items due today, at most two. Each has **Done** and **Later today**. |
23
+ | Wrapping up | From 17:00, or after noon with no meeting left | What is still open and what waits for your computer |
24
+
25
+ When the moment changes, a short message shows at the top for 6 seconds. The message shows only while the page is open. The companion does not send notifications.
26
+
27
+ ## Waiting on you
28
+
29
+ Under the moment card, your own items show one at a time ("1 of 4"). **Done** marks the item done; the message that follows has **Undo**. **Later today** shows the item again later. **Next** shows the next item. Under the card, **Later today** lists the rest of today's meetings.
30
+
31
+ ## Tell PMOS
32
+
33
+ Type a note, a to-do or an idea in the bar at the bottom and press Enter. PMOS puts it in your Work inbox. The bar does not answer questions. The small microphone puts the cursor in the bar; use the microphone of your phone's keyboard to speak. No audio goes to PMOS from this button.
34
+
35
+ ## Record
36
+
37
+ The red button records a conversation or a meeting with the same recorder as **Record** on the desktop. While it records, the bar shows the name, the time and **Stop**.
38
+
39
+ ## The full console
40
+
41
+ **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 600 pixels the console always shows the desktop pages.
42
+
43
+ ## Limits
44
+
45
+ - No notification on the lock screen, and no spoken brief.
46
+ - No recommendation for a decision. Decisions with options wait for your computer.
47
+ - To make tasks from the notes of a meeting, use the Notes page on your computer.
@@ -0,0 +1,15 @@
1
+ 'use strict';
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 600px 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. */
6
+ function fullConsole(browser) {
7
+ const make = browser.newContext.bind(browser);
8
+ browser.newContext = async (...args) => {
9
+ const ctx = await make(...args);
10
+ await ctx.addInitScript(() => { try { localStorage.setItem('pmos.c3.phone', 'desk'); } catch { /* no storage */ } });
11
+ return ctx;
12
+ };
13
+ return browser;
14
+ }
15
+ module.exports = { fullConsole };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.175.0",
3
+ "version": "0.177.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.175.0",
9
+ "version": "0.177.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.175.0",
3
+ "version": "0.177.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",
@@ -0,0 +1,14 @@
1
+ /* pm/c3-demo-phone.js — demo only (?demo=1): the notes of the recorded Checkout sync, for the phone companion's "Just ended"
2
+ * card (c3-phone.js). The shapes are the real ones: a GET /api/notes row and the note of GET /api/notes/one with final_notes. */
3
+ const NOTE = {
4
+ id: 'note-demo-checkout', title: 'Checkout sync', private: false, state: 'ended', updated_at: '2026-10-08T11:16:00Z',
5
+ meeting: { kind: 'event', event_id: 'm2', title: 'Checkout sync' },
6
+ final_notes: {
7
+ overview: 'The v2 date moves to 31 Oct because the sandbox keys are late. Beta stays on 30 Oct for the two partners already set up.',
8
+ decisions: [{ text: 'Checkout v2 ships on 31 Oct, not 24 Oct', why: 'Sandbox keys arrive two days late' }, { text: 'Beta stays on 30 Oct for the two partners already set up' }],
9
+ actions: [{ text: 'Send Priya the revised Checkout v2 date', owner: 'Wawan', due: '9 Oct' }, { text: 'Ask Dara for the sandbox keys date in writing', owner: 'Dara', due: '10 Oct' }],
10
+ questions: [{ text: 'Does the 31 Oct date move the IDPF 3.2 release?' }],
11
+ },
12
+ };
13
+ export const demoNoteFor = (m) => (m && (m.id === 'm2' || m.title === NOTE.title) ? NOTE : null);
14
+ export const demoNote = (id) => (id === NOTE.id ? { ok: true, note: NOTE } : null);
@@ -0,0 +1,55 @@
1
+ /* pm/c3-phone-model.js — which moment of the day the phone companion is in, and what it brings (initiative c3-phone-companion).
2
+ * Pure: it reads today's meetings and the PM's own open items (GET /api/brief-page) and a clock, and returns one moment. No DOM, no fetch.
3
+ *
4
+ * momentOf({ today, needs, now }) → { kind, meeting?, next?, minutes?, fits?, later }
5
+ * kind 'meeting' a meeting is under way (start ≤ now < end) meeting = that one
6
+ * 'before' the next meeting starts within BEFORE_MIN minutes meeting = the next one, minutes = until it starts
7
+ * 'after' a meeting ended less than AFTER_MIN minutes ago, nothing under way meeting = the one that ended
8
+ * 'gap' a free stretch of GAP_MIN minutes or more before the next meeting minutes = free minutes, fits = items that fit
9
+ * 'morning' before noon with no meeting under way or near next = the next meeting, or null
10
+ * 'wrap' from WRAP_HOUR on, or no meeting left today after noon next = null
11
+ * later the meetings still to come after the one the moment is about (time order), for "I'll bring up later".
12
+ * fitsIn(needs, minutes) the own items worth doing in a free stretch: overdue first (most late first), then due today; at most two.
13
+ * The meeting rows are the brief page's today[] rows: { id, title, start, end, people, initiative, prepared, before }. */
14
+
15
+ export const BEFORE_MIN = 30;
16
+ export const AFTER_MIN = 20;
17
+ export const GAP_MIN = 15;
18
+ export const WRAP_HOUR = 17;
19
+
20
+ const ms = (iso) => Date.parse(iso);
21
+ const ok = (m) => m && m.id && !Number.isNaN(ms(m.start)) && !Number.isNaN(ms(m.end || m.start));
22
+
23
+ export function fitsIn(needs, minutes) {
24
+ if (!(minutes >= GAP_MIN)) return [];
25
+ const own = (needs || []).filter((n) => n && (n.kind === 'overdue' || n.kind === 'due-today'));
26
+ own.sort((a, b) => (a.kind === b.kind ? (b.days_late || 0) - (a.days_late || 0) : a.kind === 'overdue' ? -1 : 1));
27
+ return own.slice(0, 2);
28
+ }
29
+
30
+ export function momentOf({ today, needs, now } = {}) {
31
+ const t = (now instanceof Date ? now : new Date(now || Date.now())).getTime();
32
+ const hour = new Date(t).getHours();
33
+ const rows = (today || []).filter(ok).slice().sort((a, b) => ms(a.start) - ms(b.start));
34
+ const end = (m) => ms(m.end || m.start);
35
+ const cur = rows.find((m) => ms(m.start) <= t && t < end(m));
36
+ const upcoming = rows.filter((m) => ms(m.start) > t);
37
+ const next = upcoming[0] || null;
38
+ const laterThan = (m) => upcoming.filter((x) => x !== m);
39
+ if (cur) return { kind: 'meeting', meeting: cur, minutes: Math.round((end(cur) - t) / 60000), later: upcoming };
40
+ const until = next ? Math.round((ms(next.start) - t) / 60000) : null;
41
+ if (next && until <= BEFORE_MIN) return { kind: 'before', meeting: next, minutes: until, later: laterThan(next) };
42
+ const ended = rows.filter((m) => end(m) <= t && t - end(m) < AFTER_MIN * 60000).pop();
43
+ if (ended) return { kind: 'after', meeting: ended, later: upcoming };
44
+ if (hour >= WRAP_HOUR || (!next && hour >= 12)) return { kind: 'wrap', next: null, later: upcoming };
45
+ if (next && until >= GAP_MIN && rows.some((m) => end(m) <= t)) return { kind: 'gap', next, minutes: until, fits: fitsIn(needs, until), later: upcoming };
46
+ if (hour < 12) return { kind: 'morning', next, later: upcoming };
47
+ return { kind: 'gap', next, minutes: until, fits: fitsIn(needs, until || 0), later: upcoming };
48
+ }
49
+
50
+ /** "in 25 min", "in 1 h 5 min" */
51
+ export function inText(minutes) {
52
+ const m = Math.max(0, Math.round(minutes || 0));
53
+ if (m < 60) return 'in ' + m + ' min';
54
+ return 'in ' + Math.floor(m / 60) + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '');
55
+ }
@@ -0,0 +1,49 @@
1
+ /* pm/c3-phone.css — the phone companion (c3-phone-companion, PM-approved 2026-10-12, design version 4). Linked once by c3-phone.js.
2
+ * In phone mode the root carries .c3-phone-on: the desktop frame (header, tabs, panes, dock) is not drawn and the companion fills the
3
+ * screen. One moment card, one waiting card, a short "later today" list, and a bottom bar with "Tell PMOS" and the red Record button.
4
+ * Everything is scoped under .c3 and uses tokens only. */
5
+ .c3.c3-phone-on .c3grid{display:none !important}
6
+ .c3.c3-phone-on{min-height:100dvh}
7
+ .c3 .c3ph{display:flex;flex-direction:column;min-height:100dvh;background:var(--background)}
8
+ .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}
11
+ .c3 .c3ph-dotn{display:inline-flex;align-items:center;gap:4px;font:var(--fs-1) var(--mono);color:var(--muted-foreground)}
12
+ .c3 .c3ph-body{flex:1;display:grid;gap:12px;align-content:start;padding:0 12px 16px}
13
+ .c3 .c3ph-card{display:grid;gap:8px;padding:14px}
14
+ .c3 .c3ph-card.lead{border-color:var(--acc)}
15
+ .c3 .c3ph-why,.c3 .c3ph-step{font:var(--fs-1) var(--mono);color:var(--muted-foreground);display:flex;align-items:center;gap:6px}
16
+ .c3 .c3ph-why{color:var(--acc)}
17
+ .c3 .c3ph-big{font-size:var(--fs-5);font-weight:600;line-height:1.3}
18
+ .c3 .c3ph-tt{font-size:var(--fs-3);line-height:1.4}
19
+ .c3 .c3ph-line{font-size:var(--fs-2);color:var(--muted-foreground);line-height:1.45}
20
+ .c3 .c3ph-rec-why{font-size:var(--fs-2);line-height:1.45;background:var(--muted);border-radius:8px;padding:8px 10px}
21
+ .c3 .c3ph-prep{display:flex;gap:6px;align-items:flex-start;font-size:var(--fs-2);line-height:1.45}
22
+ .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}
24
+ .c3 .c3ph-acts{display:grid;gap:8px}
25
+ .c3 .c3ph-acts.two{grid-template-columns:1fr 1fr}
26
+ .c3 .c3ph-b{min-height:44px;justify-content:center}
27
+ .c3 .c3ph-cnt{display:flex;justify-content:space-between;font-weight:600;font-size:var(--fs-2)}
28
+ .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}
30
+ .c3 .c3ph-later > div{display:flex;gap:10px}
31
+ .c3 .c3ph-later .mono{font-family:var(--mono);min-width:42px}
32
+ .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}
33
+ .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)}
34
+ .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}
35
+ .c3 .c3ph-ask:focus-within{border-color:var(--acc)}
36
+ .c3 .c3ph-recdot{width:44px;height:44px;border:2px solid var(--destructive);display:grid;place-items:center;flex:none;padding:0}
37
+ .c3 .c3ph-reddot{width:18px;height:18px;border-radius:50%;background:var(--destructive)}
38
+ .c3 .c3ph-recbar{flex:1;display:flex;align-items:center;gap:10px;min-height:44px}
39
+ .c3 .c3ph-rt{flex:1;min-width:0;display:grid}
40
+ .c3 .c3ph-rt b{color:var(--destructive);font-size:var(--fs-3)}
41
+ .c3 .c3ph-rt .dim{font-size:var(--fs-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
42
+ .c3 .c3ph-dot{width:12px;height:12px;border-radius:50%;background:var(--destructive);flex:none;animation:c3phblink 1.2s infinite}
43
+ .c3 .c3ph-dot.paused{animation:none;opacity:.5}
44
+ @keyframes c3phblink{50%{opacity:.3}}
45
+ @media (prefers-reduced-motion:reduce){.c3 .c3ph-dot{animation:none}}
46
+ .c3 .c3ph-bnhost{position:sticky;top:52px;z-index:3;padding:0 12px}
47
+ .c3 .c3ph-banner{display:grid;gap:2px;background:var(--card);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:8px;box-shadow:var(--sh3);cursor:pointer}
48
+ /* the focus page in its compact form fills the phone */
49
+ .c3.c3-phone-on .c3focus.fc-pop{position:fixed;inset:0;z-index:20}
@@ -0,0 +1,269 @@
1
+ /* pm/c3-phone.js — the phone companion (initiative c3-phone-companion). Below 601px wide, console3 shows this instead of a small copy of
2
+ * the desktop pages. Approved design: version 4 of the mockup named in planning/initiatives/c3-phone-companion.md.
3
+ *
4
+ * It has no tabs and no inbox to visit. It brings the one thing that matters now (c3-phone-model.js momentOf), then the PM's own items one
5
+ * at a time, then what comes later today. Two controls sit at the bottom: "Tell PMOS" and the red Record button.
6
+ * moment card meeting under way: "Record this meeting?" (Record / Not this one); recording: the meeting companion (c3-focus.js, compact)
7
+ * before a meeting: when it starts, who, what is prepared, the "before you go" items with Done
8
+ * just ended: tell PMOS what came out of it a free gap: the own items that fit, with Done and Later today
9
+ * morning: the next meeting and how much waits wrap: what is still open, what waits for the desk
10
+ * waiting card the own items from GET /api/brief-page needs, one at a time: Done (POST /api/pm-items/done, Undo: /reopen),
11
+ * Later today and Tomorrow (POST /api/pm-items/notyet). Decisions are not answered on the phone: they wait for the desk.
12
+ * Tell PMOS a line of text, filed by POST /api/capture { ws, text }: it lands in the Work inbox, where triage files it. The small mic
13
+ * points to the keyboard's own dictation; this page does not send audio anywhere.
14
+ * Record the red button records a conversation or a meeting, the same recorder as the desktop Record control (note-dock.js).
15
+ * While it records, the bar shows the clock and Stop. In demo mode it drives the simulated recorder and saves nothing.
16
+ * "Full console" switches this device to the desktop layout (kept on the device); the desktop More menu has "Phone companion" to come back.
17
+ * 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. */
18
+ import { el } from './dom.js';
19
+ import { button, iconButton } from './primitives.js';
20
+ import { pmPost, pmGet } from './http.js';
21
+ import { ensureC3Css, c3ic } from './pm-work-view.js';
22
+ import { onBriefPage, loadBriefPage } from './c3-brief-data.js';
23
+ import { onBriefing, loadBriefing } from './shell-ticker.js';
24
+ import { toast } from './c3-toast.js';
25
+ import { momentOf, inText } from './c3-phone-model.js';
26
+
27
+ const CSS_ID = 'c3-phone-css';
28
+ const p2 = (n) => String(n).padStart(2, '0');
29
+ const hm = (iso) => { const d = new Date(iso); return p2(d.getHours()) + ':' + p2(d.getMinutes()); };
30
+ const first = (n) => String(n || '').trim().split(/\s+/)[0];
31
+ const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
32
+ const MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
33
+ const clockOf = (msec) => { const s = Math.max(0, Math.floor(msec / 1000)); return Math.floor(s / 60) + ':' + p2(s % 60); };
34
+
35
+ function ensureCss() {
36
+ if (document.getElementById(CSS_ID)) return;
37
+ document.head.appendChild(el('link', { id: CSS_ID, rel: 'stylesheet', href: '/pm/c3-phone.css' }));
38
+ }
39
+
40
+ /** Is this device in phone mode? Phone width, and the PM has not chosen the full console on it. */
41
+ export function phoneWanted(store) {
42
+ if (typeof matchMedia !== 'function' || !matchMedia('(max-width:600px)').matches) return false;
43
+ return !(store && store.get('pmos.c3.phone') === 'desk');
44
+ }
45
+
46
+ export function mountPhone(root, ctx = {}) {
47
+ ensureC3Css(); ensureCss();
48
+ const demo = !!ctx.demo;
49
+ const q = new URLSearchParams(location.search);
50
+ const at = demo && /^\d{1,2}:\d{2}$/.test(q.get('at') || '') ? q.get('at').split(':').map(Number) : null;
51
+ const now = () => { const d = new Date(); if (at) d.setHours(at[0], at[1], 0, 0); return d; };
52
+ const S = { page: null, briefing: null, gone: new Set(), skipped: new Set(), noRec: new Set(), debrief: {}, kindKey: '', banner: null, rec: null, recKey: '' };
53
+
54
+ const host = el('div', { class: 'c3ph', 'data-c3ph': '', role: 'main', 'aria-label': 'PMOS companion' });
55
+ const head = el('div', { class: 'c3ph-hd' });
56
+ const body = el('div', { class: 'c3ph-body', 'data-c3ph-body': '' });
57
+ const bannerHost = el('div', { class: 'c3ph-bnhost' });
58
+ const input = el('input', { type: 'text', class: 'c3ph-in', 'data-c3ph-input': '', maxlength: '400', autocomplete: 'off', placeholder: 'Tell PMOS: a note, a to-do, an idea', 'aria-label': 'Tell PMOS: a note, a to-do or an idea' });
59
+ const form = el('form', { class: 'c3ph-ask', 'data-c3ph-ask': '' }, c3ic('spark', 'sm'), input,
60
+ iconButton({ icon: 'mic', ariaLabel: 'Dictate', size: 'sm', attrs: { 'data-c3ph-dictate': '' }, onClick: () => { input.focus(); toast('Use the mic on your keyboard to talk. PMOS files what you say.'); } }));
61
+ const recBtn = button({ label: '', variant: 'outline', attrs: { class: 'c3ph-recdot', 'data-c3ph-record': '', 'aria-label': 'Record a conversation or meeting', title: 'Record a conversation or meeting' }, onClick: () => startRec(null) });
62
+ recBtn.replaceChildren(el('span', { class: 'c3ph-reddot', 'aria-hidden': 'true' }));
63
+ const foot = el('div', { class: 'c3ph-ft', 'data-c3ph-foot': '' });
64
+ host.append(head, bannerHost, body, foot);
65
+ root.append(host);
66
+
67
+ /* ── recording: the desktop recorder (note-dock.js), or the demo's simulated one ── */
68
+ let dock = null;
69
+ const recLib = demo ? import('./shell-recording.js').then((m) => ({ state: () => m.simDock.recordState(), now: () => m.simDock.now(), start: (t) => m.simStart(t), stop: () => m.simDock.stopNow() }))
70
+ : import('./note-dock.js').then((m) => { m.mountDock && m.mountDock(); return { state: () => m.recordState(), now: () => Date.now(), start: (t, ev) => (ev ? m.recordEvent(ev) : m.recordNew()), stop: () => m.stopNow() }; });
71
+ recLib.then((d) => { dock = d; syncRec(); }).catch(() => { recBtn.hidden = true; });
72
+ function recState() { try { return dock ? dock.state() : { mode: 'idle' }; } catch { return { mode: 'idle' }; } }
73
+ function startRec(m) {
74
+ if (!dock) { toast('The recorder is not ready yet.'); return; }
75
+ if (recState().mode !== 'idle') { toast('Stop the recording first.'); return; }
76
+ Promise.resolve(dock.start(m ? m.title : 'Conversation', m || null)).then(() => { syncRec(); draw(); });
77
+ }
78
+ function syncRec() {
79
+ const st = recState();
80
+ const key = st.mode + ':' + (st.id || '') + ':' + (st.title || '');
81
+ if (key !== S.recKey) {
82
+ S.recKey = key;
83
+ if (st.mode === 'idle') foot.replaceChildren(form, recBtn);
84
+ else {
85
+ S.clock = el('span', { 'data-c3ph-clock': '' });
86
+ foot.replaceChildren(el('div', { class: 'c3ph-recbar', 'data-c3ph-recbar': st.mode, role: 'status' },
87
+ el('span', { class: 'c3ph-dot' + (st.mode === 'paused' ? ' paused' : '') }),
88
+ el('div', { class: 'c3ph-rt' }, el('b', null, st.mode === 'paused' ? 'Paused' : 'Recording'), el('span', { class: 'dim' }, (st.title || 'Conversation') + ' · ', S.clock)),
89
+ button({ label: 'Stop', variant: 'outline', size: 'sm', icon: 'square', attrs: { 'data-c3ph-stop': '' }, onClick: () => Promise.resolve(dock.stop()).then(() => { syncRec(); draw(); }) })));
90
+ }
91
+ }
92
+ if (S.clock) S.clock.textContent = st.mode === 'recording' && st.started ? clockOf(dock.now() - st.started) : st.mode === 'paused' ? 'paused' : '';
93
+ }
94
+
95
+ /* ── writes ── */
96
+ const post = (path, b) => pmPost(path, b).catch(() => ({ ok: false }));
97
+ function itemDone(n) {
98
+ S.gone.add(n.id); draw();
99
+ post('/api/pm-items/done', { id: n.id }).then((r) => { if (r && r.ok === false) { S.gone.delete(n.id); draw(); toast('That did not go through.'); } });
100
+ toast('Done: ' + n.headline, () => { S.gone.delete(n.id); draw(); post('/api/pm-items/reopen', { id: n.id }); });
101
+ }
102
+ function itemLater(n, when) {
103
+ S.gone.add(n.id); draw();
104
+ post('/api/pm-items/notyet', { id: n.id, when }).then((r) => { if (r && r.ok === false) { S.gone.delete(n.id); draw(); toast('That did not go through.'); } });
105
+ toast(when === 'tomorrow' ? 'Back tomorrow' : 'Back later today');
106
+ }
107
+ form.addEventListener('submit', (e) => {
108
+ e.preventDefault();
109
+ const text = input.value.trim(); if (!text) return;
110
+ input.value = ''; input.blur();
111
+ if (demo) { (window.__c3demoWrites = window.__c3demoWrites || []).push({ path: '/api/capture', body: { ws: ctx.ws, text } }); toast('Filed in your Work inbox. (Demo: nothing is saved.)'); return; }
112
+ post('/api/capture', { ws: ctx.ws, text }).then((r) => toast(r && r.ok !== false ? 'Filed in your Work inbox. PMOS sorts it.' : 'That was not filed' + (r && r.error ? ': ' + r.error : '.')));
113
+ });
114
+
115
+ /* ── the debrief of a meeting that just ended: the notes of its recording (GET /api/notes, /api/notes/one), as on the desktop ── */
116
+ function debriefOf(m) {
117
+ const k = m.id;
118
+ const have = S.debrief[k];
119
+ if (have && ((have.state === 'ready' && have.parts.from === 'final') || Date.now() - have.at < 30000)) return have;
120
+ if (!have || have.state !== 'loading') {
121
+ S.debrief[k] = { state: have ? have.state : 'loading', parts: have && have.parts, at: Date.now() };
122
+ readDebrief(m).then((d) => { S.debrief[k] = { ...d, at: Date.now() }; draw(); }, () => { S.debrief[k] = { state: 'none', at: Date.now() }; draw(); });
123
+ }
124
+ return S.debrief[k];
125
+ }
126
+ async function readDebrief(m) {
127
+ const [links, dn] = await Promise.all([demo ? import('./c3-demo-phone.js') : import('./notes-links.js'), import('./dest-notes.js')]);
128
+ let row;
129
+ if (demo) row = links.demoNoteFor(m);
130
+ else { await links.refreshNotes(); row = links.notesFor(m).slice().sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)))[0]; }
131
+ if (!row) return { state: 'none' };
132
+ if (row.private) return { state: 'private' };
133
+ const one = demo ? links.demoNote(row.id) : await pmGet('/api/notes/one?id=' + encodeURIComponent(row.id));
134
+ const n = one && one.note;
135
+ if (!n) return { state: 'none' };
136
+ const parts = dn.partsOf(n);
137
+ if (!parts.overview && !parts.decisions.length && !parts.actions.length && !parts.questions.length) return { state: 'writing' };
138
+ return { state: 'ready', parts };
139
+ }
140
+
141
+ /* ── drawing ── */
142
+ const why = (t) => el('span', { class: 'c3ph-why' }, c3ic('spark', 'sm'), t);
143
+ const big = (t) => el('div', { class: 'c3ph-big' }, t);
144
+ const line = (t, attrs) => el('div', { class: 'c3ph-line', ...(attrs || {}) }, t);
145
+ const acts = (...b) => el('div', { class: 'c3ph-acts' + (b.filter(Boolean).length === 2 ? ' two' : '') }, ...b.filter(Boolean));
146
+ const card = (hero, attrs, ...kids) => el('section', { class: 'card c3ph-card' + (hero ? ' lead' : ''), ...(attrs || {}) }, ...kids);
147
+ const btn = (label, variant, icon, attrs, onClick) => button({ label, variant, icon, attrs: { ...(attrs || {}), class: 'c3ph-b' }, onClick });
148
+ const who = (m) => (m.people && m.people.length ? m.people.map(first).join(', ') : '');
149
+ const own = () => ((S.page && S.page.needs) || []).filter((n) => n && n.id && !S.gone.has(n.id));
150
+ const decisions = () => ((S.briefing && S.briefing.needs) || []).filter((n) => n && (n.card || (n.opts || n.options || []).length) && !n.itemId).length;
151
+
152
+ function itemCard(n, hero) {
153
+ const late = n.kind === 'overdue' ? (n.days_late === 1 ? '1 day late' : n.days_late + ' days late') : n.kind === 'due-today' ? 'Due today' : n.person ? 'Talk to ' + first(n.person) : 'Outside PMOS';
154
+ return [el('span', { class: 'c3ph-step' }, late.toUpperCase()), el('b', { class: 'c3ph-tt' }, n.headline),
155
+ n.evidence && n.evidence.text ? line('Looks done: ' + n.evidence.text + '.') : null,
156
+ acts(btn(n.evidence ? 'Confirm done' : 'Done', hero ? 'primary' : 'outline', 'check', { 'data-c3ph-done': n.id }, () => itemDone(n)),
157
+ btn('Later today', 'ghost', 'clock', { 'data-c3ph-later': n.id }, () => itemLater(n, 'later-today')))];
158
+ }
159
+
160
+ function momentCard(mo) {
161
+ const m = mo.meeting;
162
+ if (mo.kind === 'meeting') {
163
+ const st = recState();
164
+ const recording = st.mode !== 'idle';
165
+ const open = btn('Open the meeting companion', recording ? 'primary' : 'outline', 'target', { 'data-c3ph-focus': m.id }, () => 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.')));
166
+ if (recording) return card(true, { 'data-c3ph-moment': 'meeting' }, why('Under way · ends ' + inText(mo.minutes)), big(m.title), line('Recording. PMOS follows the talk and writes the notes.'), acts(open));
167
+ if (S.noRec.has(m.id)) return card(true, { 'data-c3ph-moment': 'meeting' }, why('Under way · ends ' + inText(mo.minutes)), big(m.title), line('Not recording. PMOS follows the agenda and what you mark.'), acts(open, btn('Record from now', 'ghost', 'mic', { 'data-c3ph-rec-meeting': m.id }, () => startRec(m))));
168
+ return card(true, { 'data-c3ph-moment': 'meeting' }, why('Starting now' + (who(m) ? ' · ' + who(m) : '')), big('Record ' + m.title + '?'),
169
+ el('div', { class: 'c3ph-rec-why' }, 'Recording lets PMOS follow the talk, bring up what you need at the right topic, and write the notes for you.'),
170
+ acts(btn('Record', 'primary', 'mic', { 'data-c3ph-rec-meeting': m.id }, () => startRec(m)), btn('Not this one', 'ghost', null, { 'data-c3ph-norec': m.id }, () => { S.noRec.add(m.id); draw(); })));
171
+ }
172
+ if (mo.kind === 'before') {
173
+ const before = (m.before || []).filter((x) => !S.gone.has(x.id));
174
+ return card(true, { 'data-c3ph-moment': 'before' }, why('Starts ' + inText(mo.minutes) + ' · ' + hm(m.start)), big(m.title),
175
+ who(m) || m.initiative ? line([who(m), m.initiative].filter(Boolean).join(' · ')) : null,
176
+ ...(m.prepared || []).map((x) => el('div', { class: 'c3ph-prep' }, c3ic(x.kind === 'prep' ? 'spark' : 'doc', 'sm'), x.text)),
177
+ ...before.map((x) => el('div', { class: 'c3ph-before', 'data-c3ph-before': x.id }, el('span', null, 'Before you go: ' + x.title),
178
+ btn('Done', 'outline', 'check', { 'data-c3ph-done': x.id }, () => itemDone({ id: x.id, headline: x.title })))),
179
+ !(m.prepared || []).length && !before.length ? line('Nothing is prepared for it.') : null,
180
+ line('When it starts, PMOS asks to record it.', { 'data-c3ph-recnote': '' }));
181
+ }
182
+ if (mo.kind === 'after') {
183
+ const db = debriefOf(m);
184
+ const head = [why('Just ended · ' + hm(m.end || m.start)), big('What came out of ' + m.title + '?')];
185
+ const tell = btn(db.state === 'ready' ? 'Add something' : 'Tell PMOS', db.state === 'ready' ? 'outline' : 'primary', 'notebook-pen', { 'data-c3ph-tellafter': m.id }, () => { input.value = 'From ' + m.title + ': '; input.focus(); });
186
+ if (db.state === 'ready') {
187
+ const p = db.parts;
188
+ const list = (label, rows, fmt) => (rows.length ? el('div', { class: 'c3ph-fit', 'data-c3ph-debrief': label.toLowerCase() }, el('b', null, label + ' · ' + rows.length), ...rows.slice(0, 3).map((x) => el('div', { class: 'c3ph-line' }, fmt(x)))) : null);
189
+ return card(true, { 'data-c3ph-moment': 'after', 'data-c3ph-debrief-state': 'ready' }, ...head,
190
+ p.overview ? line(p.overview.length > 220 ? p.overview.slice(0, 217) + '…' : p.overview) : null,
191
+ list('Decisions', p.decisions, (x) => x.text),
192
+ list('Actions', p.actions, (x) => [x.text, x.owner ? ' · ' + x.owner : '', x.due ? ' · due ' + x.due : ''].join('')),
193
+ list('Open questions', p.questions, (x) => x.text),
194
+ line(p.from === 'final' ? 'From the recording. Make tasks from it on your computer.' : 'Caught while recording. The final notes follow.'),
195
+ acts(tell));
196
+ }
197
+ return card(true, { 'data-c3ph-moment': 'after', 'data-c3ph-debrief-state': db.state }, ...head,
198
+ line(db.state === 'writing' ? 'PMOS is writing the notes from the recording. Decisions and actions show here when they are ready.'
199
+ : db.state === 'private' ? 'The notes of this meeting are private. Open them on your computer.'
200
+ : db.state === 'loading' ? 'Looking for the notes of this meeting.'
201
+ : 'Not recorded. Tell PMOS the decisions and actions. They land in your Work inbox.'),
202
+ db.state === 'writing' || db.state === 'private' ? null : acts(tell));
203
+ }
204
+ if (mo.kind === 'gap') {
205
+ const fits = (mo.fits || []).filter((n) => !S.gone.has(n.id));
206
+ return card(true, { 'data-c3ph-moment': 'gap' }, why(mo.next ? 'Until ' + mo.next.title + ' at ' + hm(mo.next.start) : 'No more meetings today'),
207
+ big(mo.minutes ? mo.minutes + ' free minutes' : 'A free stretch'),
208
+ ...(fits.length ? fits.map((n) => el('div', { class: 'c3ph-fit', 'data-c3ph-fit': n.id }, ...itemCard(n, false))) : [line('Nothing of yours is due. A good moment to prepare the next meeting.')]));
209
+ }
210
+ if (mo.kind === 'morning') {
211
+ const o = own();
212
+ return card(true, { 'data-c3ph-moment': 'morning' }, why('Your day'), big(mo.next ? mo.next.title + ' at ' + hm(mo.next.start) : 'No meetings today'),
213
+ line([mo.next ? 'First up, ' + inText((Date.parse(mo.next.start) - now().getTime()) / 60000) + '.' : null, o.length ? o.length + (o.length === 1 ? ' thing of yours waits.' : ' things of yours wait.') : 'Nothing of yours is due.', decisions() ? decisions() + (decisions() === 1 ? ' decision waits' : ' decisions wait') + ' for your desk.' : null].filter(Boolean).join(' ')));
214
+ }
215
+ const o = own();
216
+ return card(true, { 'data-c3ph-moment': 'wrap' }, why('Wrapping up'), big(o.length ? o.length + ' still open today' : 'Nothing left for today'),
217
+ line(decisions() ? decisions() + (decisions() === 1 ? ' decision waits' : ' decisions wait') + ' for your desk. It opens first on your Briefing.' : 'Nothing waits for your desk.'));
218
+ }
219
+
220
+ function waitingCard(mo) {
221
+ const skipIds = new Set(((mo.kind === 'gap' && mo.fits) || []).map((n) => n.id));
222
+ const list = own().filter((n) => !skipIds.has(n.id) && !S.skipped.has(n.id));
223
+ if (!list.length) return null;
224
+ const n = list[0];
225
+ return card(false, { 'data-c3ph-waiting': n.id }, el('div', { class: 'c3ph-cnt' }, el('span', null, 'Waiting on you'), el('span', { class: 'dim' }, '1 of ' + list.length)),
226
+ ...itemCard(n, false), list.length > 1 ? button({ label: 'Next', variant: 'ghost', size: 'sm', iconEnd: 'chevron-right', attrs: { 'data-c3ph-skip': n.id }, onClick: () => { S.skipped.add(n.id); draw(); } }) : null);
227
+ }
228
+
229
+ function laterList(mo) {
230
+ const rows = (mo.later || []).slice(0, 3);
231
+ if (!rows.length) return null;
232
+ return el('div', { class: 'c3ph-later', 'data-c3ph-later-list': '' }, el('span', { class: 'c3ph-step' }, 'LATER TODAY'),
233
+ ...rows.map((m) => el('div', { 'data-c3ph-later-row': m.id }, el('span', { class: 'mono' }, hm(m.start)), el('span', null, m.title))));
234
+ }
235
+
236
+ function headLine() {
237
+ const d = now();
238
+ const n = own().length;
239
+ head.replaceChildren(el('b', null, WD[d.getDay()] + ' ' + d.getDate() + ' ' + MO[d.getMonth()]),
240
+ el('span', { class: 'c3ph-hdr' }, n ? el('span', { class: 'c3ph-dotn', 'data-c3ph-count': String(n) }, c3ic('inbox', 'sm'), n + ' waiting') : null,
241
+ button({ label: 'Full console', variant: 'ghost', size: 'sm', attrs: { 'data-c3ph-desk': '' }, onClick: () => ctx.onDesk && ctx.onDesk() })));
242
+ }
243
+
244
+ 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' };
245
+ function draw() {
246
+ if (!S.page) { body.replaceChildren(card(false, { 'data-c3ph-state': 'loading' }, line('Reading your day.'))); headLine(); return; }
247
+ if (S.page.ok === false) { body.replaceChildren(card(false, { 'data-c3ph-state': 'error' }, line('Your day could not be read: ' + (S.page.error || 'unreadable')))); headLine(); return; }
248
+ const mo = momentOf({ today: S.page.today || [], needs: own(), now: now() });
249
+ const key = mo.kind + ':' + ((mo.meeting && mo.meeting.id) || '');
250
+ if (S.kindKey && key !== S.kindKey) {
251
+ const t = BANNER[mo.kind](mo.meeting || mo.next || {}, mo);
252
+ bannerHost.replaceChildren(el('div', { class: 'c3ph-banner', role: 'status', 'data-c3ph-banner': mo.kind, onclick: () => bannerHost.replaceChildren() }, el('span', { class: 'c3ph-step' }, 'PMOS'), el('b', null, t)));
253
+ clearTimeout(S.banner); S.banner = setTimeout(() => bannerHost.replaceChildren(), 6000);
254
+ }
255
+ S.kindKey = key;
256
+ host.dataset.moment = mo.kind;
257
+ headLine();
258
+ body.replaceChildren(momentCard(mo), waitingCard(mo) || el('span'), laterList(mo) || el('span'));
259
+ }
260
+
261
+ const off1 = onBriefPage((j) => { S.page = j; draw(); });
262
+ const off2 = onBriefing((b) => { S.briefing = b; if (S.page) draw(); });
263
+ loadBriefPage(); loadBriefing();
264
+ draw();
265
+ const t1 = setInterval(syncRec, 1000);
266
+ const t2 = setInterval(draw, 60000);
267
+ const t3 = setInterval(() => loadBriefPage(), 5 * 60000);
268
+ return { host, refresh: draw, dispose() { clearInterval(t1); clearInterval(t2); clearInterval(t3); off1(); off2(); host.remove(); } };
269
+ }
@@ -19,6 +19,10 @@ const store = {
19
19
  export const SIZE_KEY = 'pmos.workspace.c3.term-size';
20
20
  export const PAD_KEY = 'pmos.workspace.c3.term-pad';
21
21
  export const PAD_H_KEY = 'pmos.workspace.c3.term-pad-h';
22
+ /* the session the PM opened last (its terminal here, or its row in the Sessions panel): the panel opens on it */
23
+ export const LAST_SESSION_KEY = 'pmos.workspace.c3.last-session';
24
+ export const rememberSession = (id) => { if (id) store.set(LAST_SESSION_KEY, String(id)); };
25
+ export const lastSession = () => store.get(LAST_SESSION_KEY) || null;
22
26
  const PAD_ROWS = 4; // the default height
23
27
  /** The pad's height in rows: at least 2, and at most what leaves the terminal its room in a column `room` px high with `lh` px rows. Pure, for Node. */
24
28
  export const clampPad = (rows, room, lh) => Math.max(2, Math.min(Math.round(rows), Math.max(2, Math.floor((room - 200) / (lh || 18)))));
@@ -122,6 +126,7 @@ function makePad(id, term, tab) {
122
126
 
123
127
  export async function openSessionTerm({ id, name } = {}) {
124
128
  if (!id) return null;
129
+ rememberSession(id);
125
130
  if (!name) { const r = await pmGet('/api/sessions').catch(() => null); const x = r && Array.isArray(r.sessions) ? r.sessions.find((y) => y.id === id) : null; name = x ? x.name : id; }
126
131
  const [ok, term] = await Promise.all([loadVendor(), import('./tab-term.js').catch(() => null)]);
127
132
  if (!ok || !term) { toast({ text: 'The terminal could not be loaded on this page.', tone: 'error' }); return null; }
@@ -50,6 +50,7 @@ import { freshnessLine } from './freshness-line.js';
50
50
  import { mountInitiatives } from './c3-initiatives.js';
51
51
  import { mountSettings, gearIcon, applyLook } from './c3-settings.js';
52
52
  import { mountQuiet } from './c3-quiet.js';
53
+ import { mountPhone, phoneWanted } from './c3-phone.js';
53
54
 
54
55
  /* In demo mode each tab's deck asks for its own cards (the mockup draws a different deck per tab); real mode sends no scope. Reports always sends its own (repTab). */
55
56
  const sc = (name) => (DEMO ? name : undefined);
@@ -138,6 +139,7 @@ function toggleMore() {
138
139
  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
140
  item('spark', 'Agent playbooks', () => openPlaybooks(), false, { 'data-a': 'pbopen' }),
140
141
  item('phone', 'Phone preview', () => togglePreview(), !!S.preview, { 'data-a': 'phone' }),
142
+ 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,
141
143
  item(S.theme === 'dark' ? 'sun' : 'moon', S.theme === 'dark' ? 'Light theme' : 'Dark theme', () => setTheme(S.theme === 'dark' ? 'light' : 'dark')),
142
144
  ((b) => { b.firstChild.replaceWith(gearIcon('sm')); return b; })(item('info', 'Settings', () => showTab('settings', true), false, { 'data-a': 'settings' })),
143
145
  item('term', S.dockOpen ? 'Fold the dock' : 'Open the dock', () => { S.dockOpen = !S.dockOpen; keepDock(); syncDock(); }, false, { 'data-a': 'dockt' }, 'Ctrl J'),
@@ -150,6 +152,18 @@ function toggleMore() {
150
152
  document.addEventListener('keydown', S.menuOff); document.addEventListener('pointerdown', S.menuOff);
151
153
  const first = S.menu.querySelector('.c3pg-mi:not([disabled])'); if (first) first.focus();
152
154
  }
155
+ /* The phone companion (c3-phone.js): at 600px or less, unless the PM chose the full console on this device, it fills the page and the
156
+ * desktop grid is hidden. "Full console" stores that choice; the More menu's "Phone companion" clears it. */
157
+ function syncPhone() {
158
+ const want = phoneWanted(store);
159
+ if (want && !S.phone) {
160
+ S.root.classList.add('c3-phone-on');
161
+ S.phone = mountPhone(S.root, { ws: S.ws, demo: S.demo, onDesk: () => { store.set('pmos.c3.phone', 'desk'); syncPhone(); } });
162
+ } else if (!want && S.phone) {
163
+ S.phone.dispose(); S.phone = null;
164
+ S.root.classList.remove('c3-phone-on');
165
+ }
166
+ }
153
167
  function closeMore() {
154
168
  if (!S.menu) return;
155
169
  document.removeEventListener('keydown', S.menuOff); document.removeEventListener('pointerdown', S.menuOff);
@@ -607,6 +621,8 @@ async function boot() {
607
621
  mountDaypart(root, root.querySelector('.c3top'));
608
622
  mountAmbient(root); // the ambient motion canvas, once, behind the grid
609
623
  mountQuiet(root); // the quiet layout: two rows above the content, no teaching sentences (c3-quiet-pages)
624
+ syncPhone(); // at phone width the companion replaces the desktop frame (c3-phone-companion)
625
+ if (typeof matchMedia === 'function') matchMedia('(max-width:600px)').addEventListener('change', syncPhone);
610
626
 
611
627
  S.dock.setWorkspaces(S.workspaces, S.ws);
612
628
  mountBell(S.bellHost, { go: (_, t) => goTarget(t) });
@@ -16,6 +16,7 @@ import { toast } from './c3-toast.js';
16
16
  import { pmPost } from './http.js';
17
17
  import { button } from './primitives.js';
18
18
  import { chatFeed } from './c3-session-chat.js';
19
+ import { rememberSession, lastSession } from './c3-session-term.js';
19
20
 
20
21
  const live = new WeakMap();
21
22
  const DOT = { working: 'w', queued: 'w', asking: 'n', permission: 'n', attention: 'n', not_started: 'n', failed: 'b', quiet: '', paused: 'n', done: '' };
@@ -56,7 +57,7 @@ function row(st, s, card, parts) {
56
57
  li.style.gridTemplateColumns = 'auto minmax(0,1fr) auto';
57
58
  xp.style.gridColumn = '1 / -1';
58
59
  const key = 'r-' + s.id;
59
- attachExpand(li, xp, { key, initial: st.open.has(key), onToggle: (on) => { if (on) st.open.add(key); else st.open.delete(key); } });
60
+ attachExpand(li, xp, { key, initial: st.open.has(key), onToggle: (on) => { if (on) { st.open.add(key); rememberSession(s.id); } else st.open.delete(key); } });
60
61
  return li;
61
62
  }
62
63
  const dim = (t) => el('span', { class: 'dim mono', style: 'font-size:var(--fs-2)' }, t);
@@ -180,7 +181,9 @@ export function mountSessions(host, ctx = {}) {
180
181
  disposeSessions(host);
181
182
  if (ctx && ctx.FIXTURE) return;
182
183
  ensureC3Css();
183
- const st = { src: source(), data: null, showAuto: false, last: '', gone: false, timer: null, refresh: null, redraw: null, open: new Set(), confirm: null,
184
+ // the panel opens on the session the PM opened last: its row starts open, and the first draw brings it into view
185
+ const lastId = lastSession();
186
+ const st = { src: source(), data: null, showAuto: false, last: '', gone: false, timer: null, refresh: null, redraw: null, open: new Set(lastId ? ['r-' + lastId] : []), confirm: null, reveal: lastId,
184
187
  openSession: (id) => { const r = st.data && st.data.groups ? [].concat(...Object.values(st.data.groups).filter(Array.isArray)).find((x) => x.id === id) : null;
185
188
  import('./c3-session-term.js').then((m) => m.openSessionTerm({ id, name: r ? r.name : id })); } };
186
189
  if (st.src.demo) st.openSession = () => toast('In the real workspace this opens the CLI session.');
@@ -194,6 +197,11 @@ export function mountSessions(host, ctx = {}) {
194
197
  st.last = key; st.data = r;
195
198
  if (r.ok && ctx.onNeeds) ctx.onNeeds(needsYou(r));
196
199
  if (!busy()) draw(host, st); else st.last = '';
200
+ if (st.reveal && r.ok) {
201
+ const li = host.querySelector(`[data-run="${CSS.escape(st.reveal)}"]`);
202
+ st.reveal = null;
203
+ if (li) { li.setAttribute('data-last-open', ''); requestAnimationFrame(() => li.scrollIntoView({ block: 'nearest' })); }
204
+ }
197
205
  };
198
206
  st.timer = setInterval(() => { if (!busy()) st.refresh(); }, 5000);
199
207
  if (st.src.subscribe) st.unsub = st.src.subscribe(() => st.refresh());
@@ -78,7 +78,8 @@
78
78
  "briefing-icons.test.js",
79
79
  "needs-overdue.test.js",
80
80
  "briefing-scope.test.js",
81
- "settings-store.test.js"
81
+ "settings-store.test.js",
82
+ "c3-phone-model.test.js"
82
83
  ],
83
84
  "panels": [
84
85
  "pm-shell-test.js",
@@ -145,7 +146,8 @@
145
146
  "c3-scan-test.js",
146
147
  "c3-sessions-panel-test.js",
147
148
  "c3-updates-test.js",
148
- "c3-quiet-test.js"
149
+ "c3-quiet-test.js",
150
+ "c3-phone-test.js"
149
151
  ],
150
152
  "ui": [
151
153
  "ui-test.js"
@@ -287,7 +289,9 @@
287
289
  "c3-settings-test.js": 25,
288
290
  "c3-sessions-panel-test.js": 20,
289
291
  "c3-updates-test.js": 20,
290
- "c3-quiet-test.js": 30
292
+ "c3-quiet-test.js": 30,
293
+ "c3-phone-model.test.js": 1,
294
+ "c3-phone-test.js": 20
291
295
  },
292
296
  "_comment_seconds": "Measured wall time of each suite run alone (4-CPU sandbox, 2026-10-05). Only the ORDER uses it: the slowest suites start first, and --shard deals suites to the least-loaded shard so the shards finish together. A wrong or missing number never skips a suite; a missing one counts as 10."
293
297
  }