create-pmos 0.146.0 → 0.148.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 +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-sessions-panel-test.js +126 -0
- package/workspace/guide/console-calendar.md +31 -7
- package/workspace/guide/console3-sessions.md +35 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/notes.md +1 -1
- package/workspace/lib/calendar-events.js +11 -1
- package/workspace/lib/calendar-layers.js +95 -5
- package/workspace/lib/routes-calendar-views.js +9 -4
- package/workspace/lib/routes-runs.js +12 -4
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-calendar.js +225 -32
- package/workspace/public/pm/c3-demo-cal4.js +18 -4
- package/workspace/public/pm/c3-demo-data.js +2 -2
- package/workspace/public/pm/c3-dock.js +2 -1
- package/workspace/public/pm/c3-notes.js +47 -9
- package/workspace/public/pm/c3-session-term.js +44 -0
- package/workspace/public/pm/c3-sheet.js +3 -2
- package/workspace/public/pm/c3-shell.js +2 -1
- package/workspace/public/pm/c3-terminal-data.js +1 -1
- package/workspace/public/pm/c3-terminal-icons.js +1 -1
- package/workspace/public/pm/console3.css +31 -0
- package/workspace/public/pm/note-dock.js +5 -8
- package/workspace/public/pm/run-card.js +4 -1
- package/workspace/public/pm/sessions-panel.js +40 -4
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/c3-meeting-card.json +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.148.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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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-12T02:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-12T02:49:09Z"}
|
|
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-12T02:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-12T02:49:09Z"}
|
|
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-12T02:
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-12T02:49:09Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/* c3-sessions-panel-test.js — the console3 Sessions panel (Agent surfaces): ending a session and opening one (initiative sessions-panel-end-and-open).
|
|
4
|
+
*
|
|
5
|
+
* Starts a real server with its own state directory. Before it starts, a session record is written whose process does not exist and
|
|
6
|
+
* whose file still says it runs: the server restores it as exited, and GET /api/runs must say it ended (it read "starting · 4 d" before).
|
|
7
|
+
* Then one live shell is started, and the page checks: the live row reads the server's state, not "starting"; Open the session draws the
|
|
8
|
+
* session's real terminal in a sheet on /c3.html and the address does not change; End asks first, Keep it keeps it, End ends it.
|
|
9
|
+
*/
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { spawn } = require('child_process');
|
|
13
|
+
const { WebSocket } = require('ws');
|
|
14
|
+
const { findBrowser } = require('./lib/find-browser');
|
|
15
|
+
const T = require('./test-run').configure({ name: 'c3-sessions-panel' });
|
|
16
|
+
|
|
17
|
+
const BASE = T.BASE;
|
|
18
|
+
const results = [];
|
|
19
|
+
const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + extra : ''}`); };
|
|
20
|
+
let srv = null; let browser = null;
|
|
21
|
+
const finish = async (code) => {
|
|
22
|
+
if (browser) await browser.close().catch(() => {});
|
|
23
|
+
if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
|
|
24
|
+
T.killTmux();
|
|
25
|
+
const bad = results.filter((x) => !x.cond).length;
|
|
26
|
+
console.log(`\n${results.length - bad}/${results.length} c3-sessions-panel assertions passed`);
|
|
27
|
+
process.exit(code !== undefined ? code : (bad ? 1 : 0));
|
|
28
|
+
};
|
|
29
|
+
process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
30
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
async function until(fn, ms = 8000) { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await sleep(150); } return null; }
|
|
32
|
+
async function waitFor(page, fn, arg, ms = 15000) { try { await page.waitForFunction(fn, arg, { timeout: ms }); return true; } catch { return false; } }
|
|
33
|
+
const get = (p) => fetch(BASE + p).then((r) => r.json());
|
|
34
|
+
|
|
35
|
+
function startShell(name) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const ws = new WebSocket(`ws://${T.HOST}:${T.PORT}`); const inbox = [];
|
|
38
|
+
ws.on('message', (raw) => { try { inbox.push(JSON.parse(raw)); } catch { /* ignore */ } });
|
|
39
|
+
ws.on('error', () => resolve(null));
|
|
40
|
+
ws.on('open', async () => {
|
|
41
|
+
ws.send(JSON.stringify({ t: 'new', agent: 'shell', name }));
|
|
42
|
+
const m = await until(() => inbox.find((x) => x.t === 'created' || x.t === 'error'), 8000);
|
|
43
|
+
ws.close(); resolve(m && m.t === 'created' ? m.id : null);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
(async () => {
|
|
49
|
+
const exe = findBrowser();
|
|
50
|
+
if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the c3-sessions-panel suite cannot run'); return finish(1); }
|
|
51
|
+
let chromium;
|
|
52
|
+
try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed — the c3-sessions-panel suite cannot run'); return finish(1); }
|
|
53
|
+
|
|
54
|
+
/* a record left by a server that stopped: the file says it runs, its process is gone (no tmux session by that name) */
|
|
55
|
+
const sessDir = path.join(T.STATE_DIR, 'sessions');
|
|
56
|
+
fs.mkdirSync(sessDir, { recursive: true });
|
|
57
|
+
const created = new Date(Date.now() - 4 * 864e5).toISOString();
|
|
58
|
+
fs.writeFileSync(path.join(sessDir, 'sess-900.json'), JSON.stringify({ id: 'sess-900', name: 'stale claude', agent: 'shell', state: 'working', created, tmux: 'pmos-gone-900', origin: 'manual', cwd: path.resolve(__dirname, '..'), root: path.resolve(__dirname, '..') }));
|
|
59
|
+
fs.writeFileSync(path.join(sessDir, 'sess-900.scrollback'), '');
|
|
60
|
+
|
|
61
|
+
srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT) }) });
|
|
62
|
+
await T.waitForServer(BASE);
|
|
63
|
+
|
|
64
|
+
/* ── A: /api/runs believes the server, not a stale file ── */
|
|
65
|
+
const r0 = await get('/api/runs');
|
|
66
|
+
const inGroup = (r, g, id) => ((r.groups && r.groups[g]) || []).some((s) => s.id === id);
|
|
67
|
+
ok('A1 a session whose process is gone is not listed as started by me', r0.ok && !inGroup(r0, 'startedByMe', 'sess-900'), JSON.stringify(r0.groups && r0.groups.startedByMe));
|
|
68
|
+
ok('A2 it is listed in Past', inGroup(r0, 'past', 'sess-900'));
|
|
69
|
+
|
|
70
|
+
const id = await startShell('panel shell');
|
|
71
|
+
ok('A3 a live shell starts', !!id);
|
|
72
|
+
const r1 = await until(async () => { const r = await get('/api/runs'); return inGroup(r, 'startedByMe', id) ? r : null; });
|
|
73
|
+
const row = r1 && r1.groups.startedByMe.find((s) => s.id === id);
|
|
74
|
+
ok('A4 the live shell is started by me and carries the server state', !!row && typeof row.label === 'string' && row.label.length > 0, JSON.stringify(row));
|
|
75
|
+
|
|
76
|
+
browser = await chromium.launch({ executablePath: exe });
|
|
77
|
+
const page = await (await browser.newContext({ viewport: { width: 1440, height: 1000 } })).newPage();
|
|
78
|
+
const errors = [];
|
|
79
|
+
page.on('pageerror', (e) => errors.push(String(e && e.message || e)));
|
|
80
|
+
|
|
81
|
+
/* ── B: the panel ── */
|
|
82
|
+
await page.goto(BASE + '/c3.html');
|
|
83
|
+
ok('B1 the header has the Sessions button', await waitFor(page, () => !!document.querySelector('[data-c3pg="agent-button"]'), null, 45000));
|
|
84
|
+
await page.click('[data-c3pg="agent-button"]');
|
|
85
|
+
const sel = `[data-c3t="mine"] [data-run="${id}"]`;
|
|
86
|
+
ok('B2 the live shell has a row under Started by me', await waitFor(page, (s) => !!document.querySelector(s), sel));
|
|
87
|
+
const sub = await page.evaluate((s) => (document.querySelector(s + ' .chips') || {}).textContent || '', sel);
|
|
88
|
+
ok('B3 the row does not say starting', !!sub && !/^starting/.test(sub.trim()), sub);
|
|
89
|
+
await page.click(sel + ' .nb');
|
|
90
|
+
ok('B3b a plain session draws no empty "Starting" card', await page.evaluate((s) => !document.querySelector(s + ' [data-run-host]'), sel));
|
|
91
|
+
ok('B4 the open row has End the session', await waitFor(page, (i) => !!document.querySelector(`[data-end-session="${i}"]`), id));
|
|
92
|
+
|
|
93
|
+
/* ── C: Open the session stays on this page ── */
|
|
94
|
+
const url0 = page.url();
|
|
95
|
+
await page.click(`[data-blank-panel="${id}"] [data-open-session]`);
|
|
96
|
+
ok('C1 the session opens in a terminal sheet', await waitFor(page, (i) => !!document.querySelector(`[data-c3-session-term="${i}"] .xterm`), id));
|
|
97
|
+
ok('C2 the page stays on console3', page.url() === url0, page.url());
|
|
98
|
+
await sleep(800);
|
|
99
|
+
await page.click(`[data-c3-session-term="${id}"] .xterm`);
|
|
100
|
+
await page.keyboard.type('echo c3-typed-$((40+2))\n');
|
|
101
|
+
ok('C2b what is typed in the sheet reaches the shell', !!(await until(async () => /c3-typed-42/.test((await get('/api/sessions/transcript?id=' + id + '&max=4000')).text || ''), 8000)));
|
|
102
|
+
await page.keyboard.press('Escape');
|
|
103
|
+
await sleep(300);
|
|
104
|
+
ok('C3 Esc in the terminal goes to the shell, not the sheet', await page.evaluate((i) => !!document.querySelector(`[data-c3-session-term="${i}"]`), id));
|
|
105
|
+
await page.click('[data-shell="sheet"] [aria-label="Close"]');
|
|
106
|
+
ok('C4 the close button closes the terminal sheet', await waitFor(page, () => !document.querySelector('[data-c3-session-term]')));
|
|
107
|
+
ok('C5 closing the sheet leaves the session running', await until(async () => ((await get('/api/sessions')).sessions || []).some((s) => s.id === id && s.state !== 'exited')));
|
|
108
|
+
|
|
109
|
+
/* ── D: End asks first, then ends ── */
|
|
110
|
+
await page.click('[data-c3pg="agent-button"]');
|
|
111
|
+
ok('D0 the panel opens again', await waitFor(page, (s) => !!document.querySelector(s), sel));
|
|
112
|
+
if (!(await page.isVisible(`[data-end-session="${id}"]`).catch(() => false))) await page.click(sel + ' .nb');
|
|
113
|
+
await page.click(`[data-end-session="${id}"]`);
|
|
114
|
+
ok('D1 End asks first', await waitFor(page, (i) => !!document.querySelector(`[data-end-confirm="${i}"]`), id));
|
|
115
|
+
await page.click(`[data-end-confirm="${id}"] button:not([data-end-yes])`);
|
|
116
|
+
ok('D2 Keep it keeps the session', await waitFor(page, (i) => !document.querySelector(`[data-end-confirm="${i}"]`), id) && ((await get('/api/sessions')).sessions || []).some((s) => s.id === id));
|
|
117
|
+
await page.click(`[data-end-session="${id}"]`);
|
|
118
|
+
await page.click(`[data-end-yes="${id}"]`);
|
|
119
|
+
const gone = async () => !((await get('/api/sessions')).sessions || []).some((s) => s.id === id);
|
|
120
|
+
if (!(await until(gone, 6000)) && await page.$(`[data-end-session="${id}"]`)) { await page.click(`[data-end-session="${id}"]`); await page.click(`[data-end-yes="${id}"]`); } // "asked it to end": the second press removes it
|
|
121
|
+
ok('D3 End ends the session', !!(await until(gone, 8000)));
|
|
122
|
+
ok('D4 its row leaves Started by me', await waitFor(page, (s) => !document.querySelector(s), sel));
|
|
123
|
+
|
|
124
|
+
ok('E1 the page threw no error', errors.length === 0, errors.join(' | '));
|
|
125
|
+
return finish();
|
|
126
|
+
})().catch((e) => { console.error(e); finish(1); });
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
type: Guide
|
|
3
3
|
title: "Guide: the Calendar tab of the console page"
|
|
4
|
-
description: "The Calendar tab of the console page (/c3.html): Calendar and Notes, the five zooms, the layer chips, the markers, the context of a meeting, free time, the tidy-up suggestions and the empty states."
|
|
4
|
+
description: "The Calendar tab of the console page (/c3.html): Calendar and Notes, the five zooms, the highlights, the layer chips, the markers, the context of a meeting, free time, the tidy-up suggestions and the empty states."
|
|
5
5
|
timestamp: 2026-10-12
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -17,9 +17,11 @@ In **Notes**, the list is at the left. The notes are in groups, one for each ini
|
|
|
17
17
|
|
|
18
18
|
Press a row to open the note at the right. The note has three stages. Press **Before**, **During** or **After** to change the stage.
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
Under the title, the note shows its meeting and its initiative. Press **Link to a meeting** or **Link to an initiative** to link the note. When the note has a link, press **Change** to link it to another one.
|
|
21
|
+
|
|
22
|
+
- **Before** shows what the note kept about its meeting: the agenda, what the meeting is for, what to do to be ready, what to read first, the people, and the meeting: its time, its place and who organized it. A tick shows an agenda item that was heard in the transcript. A note with no meeting says so, and has **Link to a meeting** and **Link to an initiative**.
|
|
21
23
|
- **During** shows the live notes while the note is recorded or paused. At other times it shows the transcript.
|
|
22
|
-
- **After** shows the overview, the decisions with why, the next steps with owner and due date, the open questions, the statements that differ from a file and the
|
|
24
|
+
- **After** shows **Replay** first when the sound of the note is kept on this machine. Then it shows the overview, the decisions with why, the next steps with owner and due date, the open questions, the statements that differ from a file, the highlights and the screenshots of the shared screen (**Screen**; press one to see it large). The transcript is folded at the bottom. When the final notes are written, click a text to correct it. Leave the text to save it. Press `Esc` to cancel. **Make task** adds a next step to the note's initiative.
|
|
23
25
|
|
|
24
26
|
A note that is recorded now opens on **During**. Other notes open on **After**. **Show in the calendar** goes back to the grid, on the day of the meeting, with the meeting selected. The copy button puts the note on the clipboard as Markdown.
|
|
25
27
|
|
|
@@ -47,12 +49,27 @@ The chips under the zoom bar are the layers. Each chip shows the number of items
|
|
|
47
49
|
|
|
48
50
|
All layers and Free time are on at the start. Tidy-up is off.
|
|
49
51
|
|
|
52
|
+
On Month, Quarter and Year there is one more chip, **Routine**. It shows the number of routine items in the range: report drafts, reports that go out daily or weekly, recurring meetings, stand-ups, 1:1s and due items on track. These items are counted, not listed. Press **Routine** to show every item as a marker, and press it again to go back to the highlights.
|
|
53
|
+
|
|
54
|
+
## Highlights
|
|
55
|
+
|
|
56
|
+
Every zoom starts with a short summary above the grid, for example "October highlights". It names what matters in the range:
|
|
57
|
+
|
|
58
|
+
- the releases, and the milestones that are on track
|
|
59
|
+
- the key meetings, on Day, Week and Month only. A meeting is key when it is not routine. Month names the two biggest.
|
|
60
|
+
- the reports that go out
|
|
61
|
+
- **Watch:** the milestones and releases that are late or at risk, and the due items that are late
|
|
62
|
+
|
|
63
|
+
Press a name to open the item in the detail column. When nothing significant falls in the range, the summary says so.
|
|
64
|
+
|
|
50
65
|
## The grid
|
|
51
66
|
|
|
52
67
|
- **Day** shows the timeline of the day. The markers of the day are in a strip above it.
|
|
53
68
|
- **Week** shows a timeline for each day. Each day name has the hours of meetings of that day and a small bar. The bar has three parts: time tied to a promise or a decision, routine time, and the rest of a 6 hour day. The markers of each day are in a strip under the day name. Press a day name to open that day.
|
|
54
|
-
- **Month** shows a cell for each day. A cell shows the
|
|
55
|
-
- **Quarter** shows a
|
|
69
|
+
- **Month** shows a cell for each day. A cell shows the highlight of the day in one sentence, for example "SSO general release ships." Under it, a quiet line counts the rest, for example "1 due · 2 routine". Press that line or the day number to open the day. A line under the grid says what is counted and not named. With **Routine** on, a cell shows the number of meetings and up to three markers instead, and "+2" opens the day with the other markers.
|
|
70
|
+
- **Quarter** shows a column for each month. **Year** shows a column for each quarter, with a button for each of its three months. Each column shows its highlight and a quiet line, for example "24 meetings · 3 due · 9 routine". Quarter and Year are stricter than Month: they name only milestones, releases, reports that go out and late items. Press a column's title to open that month or quarter. With **Routine** on, a column shows its markers instead.
|
|
71
|
+
|
|
72
|
+
Titles wrap onto more lines. They are never cut.
|
|
56
73
|
|
|
57
74
|
A marker has a symbol and a short title. A red edge means late or at risk of missing. An amber edge means at risk.
|
|
58
75
|
|
|
@@ -67,11 +84,18 @@ Press a marker to show its detail in the side column. The detail shows the title
|
|
|
67
84
|
Press a meeting to show its context in the side column. Under the title, a row shows where the meeting lives, the people and the initiative. Each line has a link to its source.
|
|
68
85
|
|
|
69
86
|
- For a past meeting, **What came out of it** lists the decisions and actions of its note. **What moved after it** lists the items that changed within 48 hours and refer to the meeting or its initiative.
|
|
70
|
-
- For a coming meeting,
|
|
87
|
+
- For a coming meeting, the detail shows these parts. A part with no record is left out.
|
|
88
|
+
- **In short**: the purpose from today's brief, or the initiative and its status. It also counts the items that are blocked, late or due today.
|
|
89
|
+
- **What matters today**: the items of the initiative that are blocked, late or due in the next 2 days, and your own items on it. Each line opens the item.
|
|
90
|
+
- **Since the last one**: the last note of the same meeting (the same title or calendar series). It shows the summary, the decisions and the next steps, and how many earlier notes there are. **Open the last note** opens it in **Notes**. For a private note, only its title shows.
|
|
91
|
+
- **To prepare**: what is prepared, what is still open, and how to be ready, from today's brief. The links from the brief follow.
|
|
92
|
+
- A last line names the sources that PMOS used.
|
|
93
|
+
|
|
94
|
+
The initiative of a coming meeting is the one on the event. If the event has none, it is the one in today's brief. If the brief has none, it is the one on the last note of the meeting.
|
|
71
95
|
|
|
72
96
|
When the meeting has a note, the detail has **Open the note**. It opens the note in **Notes**.
|
|
73
97
|
|
|
74
|
-
When PMOS holds nothing for a meeting, the detail says so. It does not guess.
|
|
98
|
+
When PMOS holds nothing for a past meeting, the detail says so. For a coming meeting, **What PMOS knows** tells you what is missing (no initiative, no earlier note) and what to do: link an initiative, or take a note in the meeting. It does not guess.
|
|
75
99
|
|
|
76
100
|
## Free time
|
|
77
101
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Guide
|
|
3
|
+
title: "Sessions in console3: open, read and end your sessions"
|
|
4
|
+
description: "How the Sessions panel in console3 groups your sessions, how to open a session's terminal without leaving console3, and how to end a session."
|
|
5
|
+
timestamp: 2026-10-12
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Sessions in console3: open, read and end your sessions
|
|
9
|
+
|
|
10
|
+
Press **Sessions** in the console3 header to open the panel. It shows your sessions in four groups: **On a card**, **Started by me**, **Past (7 days)** and **Automatic**.
|
|
11
|
+
|
|
12
|
+
## Read a row
|
|
13
|
+
|
|
14
|
+
Each row under **Started by me** shows the state of the session, how long ago it started, and its tree. The state comes from the workspace server, for example `working` or `quiet`.
|
|
15
|
+
|
|
16
|
+
If the server finds that the process of a session stopped (for example, after a restart), the session moves to **Past**.
|
|
17
|
+
|
|
18
|
+
## Open a session
|
|
19
|
+
|
|
20
|
+
1. Press the row to open it.
|
|
21
|
+
2. Press **Open the session**.
|
|
22
|
+
|
|
23
|
+
The terminal of the session opens in a panel on the same page. You can type in it. When the terminal has focus, Esc and Tab go to the terminal. To close the panel, press its close button. The session continues to run.
|
|
24
|
+
|
|
25
|
+
The **Open the session** link in the sessions dock opens the same panel.
|
|
26
|
+
|
|
27
|
+
## End a session
|
|
28
|
+
|
|
29
|
+
1. Press the row to open it.
|
|
30
|
+
2. Press **End the session**.
|
|
31
|
+
3. Press **End** to confirm, or press **Keep it** to cancel.
|
|
32
|
+
|
|
33
|
+
When you end a session, its process stops and the session is removed. Any pending change stays a proposal. If the process does not stop immediately, press **End** again to remove it.
|
|
34
|
+
|
|
35
|
+
A session that is on a card has the same **End the session** control below its card.
|
package/workspace/guide/index.md
CHANGED
|
@@ -69,4 +69,5 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
|
|
|
69
69
|
- [Integrated work](integrated-work.md): how `pmos verify-work` turns a merged commit into Done, and what "Done · your call · not merged" means.
|
|
70
70
|
- [Report slides](report-slides.md): how the Reports page shows a report as the PM-OS biweekly slides, Present and Print / PDF, and how your notes become the words on the slides.
|
|
71
71
|
- [Reports: dashboards of reports](reports-dashboards.md): the console Reports page, its dashboards at portfolio, board and initiative scope, how to edit and share one as a draft, and the Drafts tab.
|
|
72
|
+
- [Sessions in console3](console3-sessions.md): the Sessions panel, how to open the terminal of a session on the same page, and how to end a session.
|
|
72
73
|
- [Settings in console3](settings.md): how to open Settings, what each section holds, the scope tags, the environment notes, and where the settings are kept.
|
package/workspace/guide/notes.md
CHANGED
|
@@ -46,7 +46,7 @@ The live transcript keeps each sentence once:
|
|
|
46
46
|
|
|
47
47
|
**Stop** ends the meeting for good. PMOS asks first: **Cancel**, **Pause instead** (while recording) or **Stop for good**. A stopped meeting cannot be resumed. PMOS writes the final notes at once.
|
|
48
48
|
|
|
49
|
-
If you start a note by mistake and stop it
|
|
49
|
+
If you start a note by mistake and stop it before anything was heard, and you typed nothing, PMOS deletes the note. A short note with speech in it is kept. The meeting you paused before it can be resumed again.
|
|
50
50
|
|
|
51
51
|
## While the meeting is held
|
|
52
52
|
|
|
@@ -31,6 +31,9 @@ function localIso(ms) {
|
|
|
31
31
|
const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
|
|
32
32
|
const minuteKey = (ms, title) => Math.floor(ms / 60000) + '|' + calMem.normTitle(title);
|
|
33
33
|
|
|
34
|
+
/** The brief names an initiative as { id, why }; memory and imports as an id. The event keeps the id. */
|
|
35
|
+
const iniId = (v) => (v && typeof v === 'object' ? (typeof v.id === 'string' && v.id ? v.id : null) : (typeof v === 'string' && v ? v : null));
|
|
36
|
+
|
|
34
37
|
/** A remembered or briefed event as an imported one. `src` says where it came from. */
|
|
35
38
|
function asEvent(e, src) {
|
|
36
39
|
const s = Date.parse(e.start);
|
|
@@ -38,7 +41,14 @@ function asEvent(e, src) {
|
|
|
38
41
|
const f = e.end && !Number.isNaN(Date.parse(e.end)) ? Date.parse(e.end) : s + 30 * 60000;
|
|
39
42
|
const people = e.with ? String(e.with).split(/\s*,\s*/).filter(Boolean) : [];
|
|
40
43
|
return { id: String(e.id || calMem.seriesKey(e) + '@' + s), at: localIso(s), end: localIso(f), title: String(e.title), attendees: people.slice(0, 12),
|
|
41
|
-
initiative: e.initiative
|
|
44
|
+
initiative: iniId(e.initiative), ...(e.initiative && typeof e.initiative === 'object' && e.initiative.why ? { initiative_why: String(e.initiative.why) } : {}),
|
|
45
|
+
agent_prep_notes: typeof e.prep === 'string' ? e.prep : '', pm_notes: [], src, label: 'reported', trust: 'untrusted',
|
|
46
|
+
// what the brief says the PM needs to walk in (the meeting card reads it): purpose, steps, links and references
|
|
47
|
+
...(typeof e.purpose === 'string' && e.purpose.trim() ? { purpose: e.purpose } : {}),
|
|
48
|
+
...(e.steps && typeof e.steps === 'object' && !Array.isArray(e.steps) ? { steps: e.steps } : {}),
|
|
49
|
+
...(Array.isArray(e.links) && e.links.length ? { links: e.links } : {}),
|
|
50
|
+
...(Array.isArray(e.refs) && e.refs.length ? { refs: e.refs } : {}),
|
|
51
|
+
...(e.series ? { series: String(e.series) } : {}) };
|
|
42
52
|
}
|
|
43
53
|
|
|
44
54
|
function readAll(runtimeDir, { now = Date.now() } = {}) {
|
|
@@ -36,6 +36,9 @@ const SUGGEST_DAYS = 7;
|
|
|
36
36
|
const WINDOW_MS = 48 * 3600000;
|
|
37
37
|
const PER_KIND = { decision: 6, action: 6, moved: 8, prepared: 8, todo: 8 };
|
|
38
38
|
const LIGHT_VIEWS = ['quarter', 'year'];
|
|
39
|
+
/* a report cadence that recurs more often than monthly is routine: the Calendar counts it instead of naming it (calendar-highlights) */
|
|
40
|
+
const ROUTINE_CADENCE = /\b(daily|weekly|bi-?weekly|fortnightly|stand-?up)\b/i;
|
|
41
|
+
const routineCadence = (c) => ROUTINE_CADENCE.test(String(c || ''));
|
|
39
42
|
|
|
40
43
|
const pad = (n) => String(n).padStart(2, '0');
|
|
41
44
|
const isDay = (s) => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(Date.parse(s.slice(0, 10) + 'T00:00:00Z'));
|
|
@@ -130,7 +133,7 @@ function layersFrom(dated, range, today) {
|
|
|
130
133
|
const date = r.issued && isDay(r.issued.at) ? day10(r.issued.at) : day10(r.slot);
|
|
131
134
|
n++;
|
|
132
135
|
put('reports', { id: `report:${r.cadence}:${day10(r.slot)}`, date, kind: 'report', layer: 'reports', title: `${r.cadence} report${r.issued ? '' : ' (not issued)'}`,
|
|
133
|
-
tone: r.issued ? 'ok' : null, ref: { type: 'report', id: `${r.cadence}/${day10(r.slot)}` } });
|
|
136
|
+
tone: r.issued ? 'ok' : null, cadence: r.cadence, routine: routineCadence(r.cadence), ref: { type: 'report', id: `${r.cadence}/${day10(r.slot)}` } });
|
|
134
137
|
}
|
|
135
138
|
for (const s of d.reportSchedules || []) {
|
|
136
139
|
if (!s || !s.id) continue;
|
|
@@ -138,7 +141,7 @@ function layersFrom(dated, range, today) {
|
|
|
138
141
|
if (!isDay(dt)) continue;
|
|
139
142
|
n++;
|
|
140
143
|
put('reports', { id: `report-sched:${s.id}:${day10(dt)}`, date: day10(dt), kind: 'report', layer: 'reports', title: `${str(s.name) || s.cadence || s.id} (scheduled)`, tone: null,
|
|
141
|
-
ref: { type: 'report', id: s.cadence || s.id } });
|
|
144
|
+
...(s.cadence ? { cadence: s.cadence } : {}), routine: routineCadence(s.cadence) || routineCadence(s.name), ref: { type: 'report', id: s.cadence || s.id } });
|
|
142
145
|
}
|
|
143
146
|
}
|
|
144
147
|
if (!n) why('reports', 'no report file and no report schedule gives an issue date');
|
|
@@ -367,17 +370,104 @@ function contextFrom(events, range, src, opts = {}) {
|
|
|
367
370
|
}
|
|
368
371
|
const kept = capKinds(items);
|
|
369
372
|
context[e.id] = { phase: past ? 'past' : 'upcoming', held: kept.length > 0, items: kept };
|
|
373
|
+
if (!past) { const b = briefOf(e, notes, s, { today, inis: opts.inis }); if (b) context[e.id].brief = b; }
|
|
370
374
|
}
|
|
371
375
|
return { context, context_total: inside.length };
|
|
372
376
|
}
|
|
373
377
|
|
|
378
|
+
/* ── the card before a meeting (initiative c3-meeting-card) ───────────────────────────────────────────────────────
|
|
379
|
+
* What PMOS already holds about a coming meeting, put together for the Calendar's detail card. No model runs: each part is
|
|
380
|
+
* read from a record, and a part with no record is left out. null when nothing at all is known.
|
|
381
|
+
* { initiative:{id,title,status}|null, gist:[sentence], matters:[{tone,tag,text,meta,ref}], last:{id,title,at,private,
|
|
382
|
+
* overview,decisions,actions}|null, earlier, purpose, prep, links:[{label,url}], refs:[{label,source,link}], sources:[name] } */
|
|
383
|
+
const MATTERS_CAP = 5;
|
|
384
|
+
const iniIdOf = (v) => (v && typeof v === 'object' ? str(v.id) : str(v)) || null;
|
|
385
|
+
|
|
386
|
+
/** The earlier notes of the same meeting: the same calendar series or the same title, started before this one, newest first.
|
|
387
|
+
* The notes that match this occurrence are not earlier notes. */
|
|
388
|
+
function seriesNotes(e, notes, current) {
|
|
389
|
+
const start = Date.parse(e.at || e.start);
|
|
390
|
+
const title = norm(e.title);
|
|
391
|
+
const mine = new Set((current || []).map((n) => n.id));
|
|
392
|
+
return notes.filter((n) => {
|
|
393
|
+
const m = n && n.meeting; if (!m || mine.has(n.id)) return false;
|
|
394
|
+
const t = Date.parse(m.start); if (!Number.isFinite(t) || !(t < start)) return false;
|
|
395
|
+
if (e.series && m.series && String(e.series) === String(m.series)) return true;
|
|
396
|
+
return title.length >= 2 && norm(m.title) === title;
|
|
397
|
+
}).sort((a, b) => Date.parse(b.meeting.start) - Date.parse(a.meeting.start));
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The initiative's items worth raising: blocked, late, or due within WARN_DAYS, not done. Own items on it likewise. */
|
|
401
|
+
function mattersOf(iniId, src, today) {
|
|
402
|
+
if (!iniId || !today) return [];
|
|
403
|
+
const soon = addDays(today, WARN_DAYS);
|
|
404
|
+
const dueTag = (d) => (d < today ? ['bad', 'Late'] : d === today ? ['warn', 'Due today'] : ['warn', 'Due soon']);
|
|
405
|
+
const out = [];
|
|
406
|
+
for (const p of src.planItems || []) {
|
|
407
|
+
if (!p || !p.id || p.initiative !== iniId || p.state === 'done' || p.state === 'withdrawn') continue;
|
|
408
|
+
const due = isDay(p.due) ? day10(p.due) : null;
|
|
409
|
+
const meta = [str(p.owner), due && 'due ' + due].filter(Boolean).join(' · ');
|
|
410
|
+
if (p.blocked) out.push({ rank: 0, tone: 'bad', tag: 'Blocked', text: str(p.title) + (str(p.blocked.reason) ? ': ' + str(p.blocked.reason) : ''), meta, ref: { type: 'plan-item', id: p.id, initiative: iniId } });
|
|
411
|
+
else if (due && due <= soon) { const [tone, tag] = dueTag(due); out.push({ rank: due < today ? 1 : 2, due, tone, tag, text: str(p.title), meta, ref: { type: 'plan-item', id: p.id, initiative: iniId } }); }
|
|
412
|
+
}
|
|
413
|
+
for (const o of src.ownItems || []) {
|
|
414
|
+
if (!o || !o.id || o.initiative !== iniId || o.done || o.done_at) continue;
|
|
415
|
+
const due = isDay(o.due) ? day10(o.due) : null;
|
|
416
|
+
if (!due || due > soon) continue;
|
|
417
|
+
const [tone, tag] = dueTag(due);
|
|
418
|
+
out.push({ rank: due < today ? 1 : 2, due, tone, tag, text: str(o.title), meta: 'yours · due ' + due, ref: { type: 'item', id: o.id } });
|
|
419
|
+
}
|
|
420
|
+
out.sort((a, b) => a.rank - b.rank || String(a.due || '').localeCompare(String(b.due || '')) || (a.text < b.text ? -1 : 1));
|
|
421
|
+
return out.slice(0, MATTERS_CAP).map(({ rank, due, ...x }) => x);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many || one + 's'}`;
|
|
425
|
+
|
|
426
|
+
function briefOf(e, current, s, { today, inis } = {}) {
|
|
427
|
+
const earlier = seriesNotes(e, s.notes || [], current);
|
|
428
|
+
const lastNote = earlier[0] || null;
|
|
429
|
+
const iniId = iniIdOf(e.initiative) || (earlier.find((n) => n.initiative) || {}).initiative || null;
|
|
430
|
+
const row = iniId ? (inis || []).find((g) => g && g.id === iniId) : null;
|
|
431
|
+
const initiative = iniId ? { id: iniId, title: str(row && row.title) || iniId, status: str(row && row.status) || null } : null;
|
|
432
|
+
const matters = mattersOf(iniId, s, today);
|
|
433
|
+
const purpose = str(e.purpose) || null;
|
|
434
|
+
const why = e.initiative && typeof e.initiative === 'object' ? str(e.initiative.why) : str(e.initiative_why);
|
|
435
|
+
const gist = [];
|
|
436
|
+
if (purpose) gist.push(/[.!?]$/.test(purpose) ? purpose : purpose + '.');
|
|
437
|
+
else if (initiative) gist.push(`For ${initiative.title}` + (initiative.status && !['no plan', 'on time'].includes(initiative.status) ? `, which is ${initiative.status}.` : '.'));
|
|
438
|
+
if (why) gist.push(/[.!?]$/.test(why) ? why : why + '.');
|
|
439
|
+
const blocked = matters.filter((x) => x.tag === 'Blocked').length; const late = matters.filter((x) => x.tag === 'Late').length;
|
|
440
|
+
const dueNow = matters.filter((x) => x.tag === 'Due today').length;
|
|
441
|
+
const parts = [blocked && plural(blocked, 'item') + ' blocked', late && plural(late, 'item') + ' late', dueNow && plural(dueNow, 'item') + ' due today'].filter(Boolean);
|
|
442
|
+
if (parts.length) gist.push(parts.join(', ').replace(/^./, (c) => c.toUpperCase()) + '.');
|
|
443
|
+
let last = null;
|
|
444
|
+
if (lastNote) {
|
|
445
|
+
last = { id: lastNote.id, title: str(lastNote.title) || str(lastNote.meeting.title), at: lastNote.meeting.start, private: !!lastNote.private };
|
|
446
|
+
if (!lastNote.private) {
|
|
447
|
+
last.overview = str(lastNote.overview) || null;
|
|
448
|
+
last.decisions = (lastNote.decisions || []).map((d) => str(d && d.text)).filter(Boolean).slice(0, 4);
|
|
449
|
+
last.actions = (lastNote.actions || []).filter((a) => str(a && a.text)).slice(0, 5).map((a) => ({ text: str(a.text), owner: str(a.owner) || null, due: str(a.due) || null }));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const links = (Array.isArray(e.links) ? e.links : []).filter((l) => l && str(l.url) && /^https?:\/\//.test(l.url)).slice(0, 5).map((l) => ({ label: str(l.label) || str(l.url), url: str(l.url) }));
|
|
453
|
+
const refs = (Array.isArray(e.refs) ? e.refs : []).filter((r) => r && str(r.label)).slice(0, 5).map((r) => ({ label: str(r.label), source: str(r.source) || null, link: /^https?:\/\//.test(str(r.link)) ? str(r.link) : null }));
|
|
454
|
+
const prep = str(e.agent_prep_notes) || null;
|
|
455
|
+
const sources = ['the calendar'];
|
|
456
|
+
if (purpose || why || prep || links.length || refs.length) sources.push("today's brief");
|
|
457
|
+
if (earlier.length) sources.push(plural(earlier.length, 'earlier note') + ' of this meeting');
|
|
458
|
+
if (initiative) sources.push('the ' + initiative.id + ' plan');
|
|
459
|
+
if (matters.some((x) => x.ref.type === 'item')) sources.push('your items');
|
|
460
|
+
if (!gist.length && !matters.length && !last && !links.length && !refs.length && !prep && !initiative) return null;
|
|
461
|
+
return { initiative, gist: gist.slice(0, 3), matters, last, earlier: earlier.length, purpose, prep, links, refs, sources };
|
|
462
|
+
}
|
|
463
|
+
|
|
374
464
|
/* ── all three ─────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
375
465
|
|
|
376
|
-
function build({ range, today, nowMs, events, dated, src, own, planItemsDue }) {
|
|
466
|
+
function build({ range, today, nowMs, events, dated, src, own, planItemsDue, inis }) {
|
|
377
467
|
const l = layersFrom(dated, range, today);
|
|
378
468
|
const free = freeFrom(events, range, today, { nowMs, own: own || (dated && dated.own) || [], planItems: planItemsDue || (dated && dated.planItems) || [] });
|
|
379
|
-
const c = contextFrom(events, range, src, { today, nowMs });
|
|
469
|
+
const c = contextFrom(events, range, src, { today, nowMs, inis });
|
|
380
470
|
return { layers: l.layers, layers_total: l.layers_total, layers_note: l.layers_note, free, context: c.context, context_total: c.context_total };
|
|
381
471
|
}
|
|
382
472
|
|
|
383
|
-
module.exports = { layersFrom, freeFrom, contextFrom, build, toneOf, prepOf, spanOf, prepRisks, minutesOn, localDay, minuteOfDay, LAYER_CAP, CONTEXT_CAP, MIN_BLOCK, WINDOW_MS };
|
|
473
|
+
module.exports = { layersFrom, freeFrom, contextFrom, briefOf, seriesNotes, mattersOf, build, toneOf, prepOf, spanOf, prepRisks, minutesOn, localDay, minuteOfDay, LAYER_CAP, CONTEXT_CAP, MIN_BLOCK, WINDOW_MS };
|
|
@@ -55,7 +55,9 @@ function handle(query, deps = {}) {
|
|
|
55
55
|
const items = typeof deps.readItems === 'function' ? (deps.readItems() || {}) : {};
|
|
56
56
|
const src = typeof deps.readContextSources === 'function' ? (deps.readContextSources() || {}) : {};
|
|
57
57
|
const nowMs = Number.isFinite(deps.nowMs) ? deps.nowMs : (deps.today ? undefined : Date.now());
|
|
58
|
-
|
|
58
|
+
// the initiative of a coming meeting: its title from the facts, its status from the Gantt row when the row is in range
|
|
59
|
+
const iniRows = inis.filter((i) => i && i.id).map((i) => { const g = gantt.find((x) => x.id === i.id); return { id: i.id, title: i.title || i.id, status: g ? g.status : null }; });
|
|
60
|
+
const extra = layersLib.build({ range, today: nowDay, nowMs, events, dated, src, own: items.own, planItemsDue: items.planItems, inis: iniRows });
|
|
59
61
|
const tidy = tidyLib.tidyFrom({ range, today: nowDay, nowMs, events, eventsSource: source, inis, gantt,
|
|
60
62
|
planItems: dated.planItems || items.planItems || [] });
|
|
61
63
|
return { status: 200, body: { ok: true, view, range, unit: b.unit, cells: b.cells, gantt, strategy, events_source: source, today: nowDay, ...extra, ...tidy } };
|
|
@@ -118,8 +120,8 @@ function readers(root, runtimeDir) {
|
|
|
118
120
|
},
|
|
119
121
|
readContextSources() {
|
|
120
122
|
const src = { notes: [], planItems: [], ownItems: [] };
|
|
121
|
-
try { src.planItems = planRows().map((p) => ({ id: p.id, initiative: p.initiative, title: p.title, createdAt: p.createdAt, doneAt: p.doneAt })); } catch { /* no plan ledger: no moved items */ }
|
|
122
|
-
try { src.ownItems = ownRows().map((o) => ({ id: o.id, title: o.title, initiative: o.initiative || null, created_at: o.created_at || null, done_at: o.done_at || null })); } catch { /* no own items */ }
|
|
123
|
+
try { src.planItems = planRows().map((p) => ({ id: p.id, initiative: p.initiative, title: p.title, createdAt: p.createdAt, doneAt: p.doneAt, state: p.state, blocked: p.blocked ? { reason: p.blocked.reason } : null, due: p.due || null, owner: p.owner || null })); } catch { /* no plan ledger: no moved items */ }
|
|
124
|
+
try { src.ownItems = ownRows().map((o) => ({ id: o.id, title: o.title, initiative: o.initiative || null, created_at: o.created_at || null, done_at: o.done_at || null, due: o.due || null, done: !!o.done })); } catch { /* no own items */ }
|
|
123
125
|
try { src.notes = linkedNotes(runtimeDir); } catch { /* no notes: no decisions or actions */ }
|
|
124
126
|
return src;
|
|
125
127
|
},
|
|
@@ -165,8 +167,11 @@ function linkedNotes(runtimeDir) {
|
|
|
165
167
|
const fin = n.final_notes && typeof n.final_notes === 'object' ? n.final_notes : null;
|
|
166
168
|
const sec = n.summary && n.summary.sections ? n.summary.sections : null;
|
|
167
169
|
const pick = (a, b) => (Array.isArray(a) && a.length ? a : Array.isArray(b) ? b : []);
|
|
170
|
+
const ov = fin && typeof fin.overview === 'string' && fin.overview.trim() ? fin.overview
|
|
171
|
+
: sec && Array.isArray(sec.overview) ? sec.overview.slice(0, 3).map((x) => String((x && x.text) || '')).filter(Boolean).join(' ') : '';
|
|
168
172
|
out.push({ id: n.id, title: n.title || '', private: !!n.private, initiative: n.initiative && n.initiative.id ? String(n.initiative.id) : null,
|
|
169
|
-
meeting: { event_id: n.meeting.event_id || null, start: n.meeting.start, title: n.meeting.title },
|
|
173
|
+
meeting: { event_id: n.meeting.event_id || null, start: n.meeting.start, title: n.meeting.title, series: n.meeting.context && n.meeting.context.series || null },
|
|
174
|
+
overview: String(ov).replace(/\s+/g, ' ').trim().slice(0, 600),
|
|
170
175
|
decisions: pick(fin && fin.decisions, sec && sec.decisions).map((x) => ({ text: x && x.text })),
|
|
171
176
|
actions: pick(fin && fin.actions, sec && sec.actions).map((x) => ({ text: x && x.text, owner: x && x.owner || null, due: x && x.due || null })) });
|
|
172
177
|
}
|
|
@@ -23,7 +23,10 @@ const { appendOutbox } = require(repoScript('outbox.js'));
|
|
|
23
23
|
const prefixes = ['/api/runs'];
|
|
24
24
|
const RUN_RE = /^sess-\d+$/;
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// `live` (optional): the server's own session list (ctx.sessionList()), by id. The meta file on disk is not rewritten when a
|
|
27
|
+
// restart finds a session's process gone, so a file can still say it runs days later. When the list is given it decides:
|
|
28
|
+
// a session it calls exited, or does not hold at all, has ended. Its presented state and label ride along for the panel.
|
|
29
|
+
function sessionsOf(sessDir, live) {
|
|
27
30
|
let names = [];
|
|
28
31
|
try { names = fs.readdirSync(sessDir).filter((n) => /^sess-\d+\.json$/.test(n)); } catch { return []; }
|
|
29
32
|
const out = [];
|
|
@@ -31,9 +34,12 @@ function sessionsOf(sessDir) {
|
|
|
31
34
|
let meta; let st;
|
|
32
35
|
try { const f = path.join(sessDir, n); meta = JSON.parse(fs.readFileSync(f, 'utf8')); st = fs.statSync(f); } catch { continue; }
|
|
33
36
|
if (!meta || !meta.id) continue;
|
|
34
|
-
const
|
|
37
|
+
const row = live ? live.get(meta.id) : null;
|
|
38
|
+
const exited = meta.state === 'exited' || (live ? !row || row.state === 'exited' : false);
|
|
39
|
+
const exitedAt = Date.parse(meta.exitedAt);
|
|
35
40
|
out.push({ id: meta.id, plan_item: meta.plan_item || null, name: meta.name || meta.id, origin: meta.origin || 'manual', cardId: meta.plan_item || null,
|
|
36
|
-
startedAt: meta.created || null, endedAt: exited ? st.mtimeMs : null
|
|
41
|
+
startedAt: meta.created || null, endedAt: exited ? (Number.isFinite(exitedAt) ? exitedAt : st.mtimeMs) : null,
|
|
42
|
+
shown: row ? row.shown || null : null, label: row ? row.label || null : null });
|
|
37
43
|
}
|
|
38
44
|
return out;
|
|
39
45
|
}
|
|
@@ -61,7 +67,9 @@ function lastAtOf(lines) {
|
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
function view(ctx, now = Date.now()) {
|
|
64
|
-
|
|
70
|
+
let live = null;
|
|
71
|
+
if (typeof ctx.sessionList === 'function') { try { live = new Map(ctx.sessionList().map((r) => [r.id, r])); } catch { live = null; } }
|
|
72
|
+
const sessions = sessionsOf(ctx.sessDir || path.join(ctx.runtimeDir, 'sessions'), live);
|
|
65
73
|
const answers = runAnswers.readAnswers(runAnswers.answersFile(ctx.runtimeDir));
|
|
66
74
|
const groups = groupSessions(sessions, now);
|
|
67
75
|
const cards = {};
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.148.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.148.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED