create-pmos 0.231.0 → 0.233.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.231.0",
3
+ "version": "0.233.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@26c827f
1
+ kernel-source: pmos@c113f92
2
2
  assembled: 2026-10-14
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-14T08:36:52Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T09:17:36Z"}
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-14T08:36:52Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T09:17:36Z"}
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-14T08:36:52Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T09:17:36Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -57,9 +57,9 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
57
57
  // old 900ms was tight enough that a server under load read as "not running" — the launcher would
58
58
  // then start a second one and collide with the first.
59
59
  const PROBE_MS = 2500;
60
- function probe(port) {
60
+ function probe(port, ms = PROBE_MS) {
61
61
  return new Promise((res) => {
62
- const req = http.get(`${urlOf(port)}/api/sessions`, { timeout: PROBE_MS }, (r) => {
62
+ const req = http.get(`${urlOf(port)}/api/sessions`, { timeout: ms }, (r) => {
63
63
  let b = ''; r.on('data', (c) => (b += c));
64
64
  r.on('end', () => {
65
65
  let j = null; try { j = JSON.parse(b); } catch {}
@@ -94,6 +94,31 @@ async function discover() {
94
94
  return hits.filter(Boolean);
95
95
  }
96
96
 
97
+ /**
98
+ * Our own servers that are running but did not answer the probe (lib/server-discovery.js busyServers).
99
+ * Pinned launches only look at the pin, the same as discover().
100
+ */
101
+ async function busy(livePorts = []) {
102
+ const held = [];
103
+ for (const p of D.candidatePorts({ stateDir: STATE, pinned: PINNED })) if (!livePorts.includes(p) && !(await portFree(p))) held.push(p);
104
+ return D.busyServers(STATE, { livePorts, heldPorts: held });
105
+ }
106
+
107
+ // How long to wait for a busy server before giving up on it. A server that is only slow answers well
108
+ // inside this; one that is stuck does not, and starting a second server beside it does not help.
109
+ const BUSY_WAIT_MS = 60000;
110
+ async function waitForBusy(servers) {
111
+ process.stdout.write(`the workspace on port ${servers[0].port} is busy; waiting for it to answer `);
112
+ const until = Date.now() + BUSY_WAIT_MS;
113
+ while (Date.now() < until) {
114
+ for (const b of servers) if (await probe(b.port, 10000)) { say(''); return b.port; }
115
+ process.stdout.write('.');
116
+ await sleep(1000);
117
+ }
118
+ say('');
119
+ return null;
120
+ }
121
+
97
122
  /** Can we bind it ourselves? The only honest test of "free" — lsof answers a different question. */
98
123
  function portFree(port) {
99
124
  return new Promise((res) => {
@@ -257,7 +282,7 @@ async function servesTailnet(port) {
257
282
 
258
283
  // `remote` starts a NEW server in tailnet mode (pmos phone); a server that is already running is reused as it is.
259
284
  // `open: false` registers the repo without opening the desktop browser.
260
- async function up(dir, { remote = false, open = true } = {}) {
285
+ async function up(dir, { remote = false, open = true, waited = false } = {}) {
261
286
  // 1. Is a workspace already running — on ANY port it recorded, not just the one this shell guesses?
262
287
  let live = await discover();
263
288
  // A server that predates this install is running OLD code under NEW files (or the reverse). Reusing
@@ -289,6 +314,15 @@ async function up(dir, { remote = false, open = true } = {}) {
289
314
  }
290
315
  return openWorkspace(dir, port, { open });
291
316
  }
317
+ // 2. Is one of OUR servers running but too busy to answer? Then wait for it. Starting a second
318
+ // server on the next port is what left a Windows PM with two servers and neither loading.
319
+ const slow = waited ? [] : await busy();
320
+ if (slow.length) {
321
+ if (await waitForBusy(slow)) return up(dir, { remote, open, waited: true }); // re-run: it may be an older version
322
+ say(`✗ the workspace server on port ${slow[0].port} (pid ${slow[0].pid}) is running but has not answered for a minute.`);
323
+ say(' Run `pmos stop`, then `pmos` to restart it. If it happens again, run `pmos doctor` and send its output.');
324
+ return 1;
325
+ }
292
326
  if (!ensureInstalled()) return 1;
293
327
  fs.mkdirSync(STATE, { recursive: true });
294
328
  cleanStalePids([]);
@@ -337,7 +371,10 @@ async function up(dir, { remote = false, open = true } = {}) {
337
371
  child.unref();
338
372
  try { fs.writeFileSync(pidFile(port), String(child.pid)); } catch {}
339
373
  process.stdout.write('starting the workspace ');
340
- for (let i = 0; i < 40 && !exited; i++) { // up to ~10s
374
+ // Up to ~60s while the server process is alive. A first start on Windows (antivirus scanning every
375
+ // new file) can take longer than 10s, and giving up early left a server starting with nobody waiting.
376
+ for (let i = 0; i < 240 && !exited; i++) {
377
+ if (i === 40) process.stdout.write('\n still starting — a first start can take up to a minute ');
341
378
  if (await probe(port)) {
342
379
  say('');
343
380
  say(' sessions keep running after you close this terminal · `pmos stop` shuts it down');
@@ -404,7 +441,8 @@ async function stopOne(port) {
404
441
  if (!pid) { say(`✗ could not locate the server on port ${port} (no pid file, and nothing I can see owns it). Stop it manually.`); return 1; }
405
442
  try { process.kill(pid); } catch (e) { say(`✗ could not signal pid ${pid}: ${e.message}`); return 1; }
406
443
  for (let i = 0; i < 20; i++) {
407
- if (!(await probe(port))) { say(`✓ stopped — ${urlOf(port)}`); try { fs.unlinkSync(pidFile(port)); } catch {} return 0; }
444
+ // Gone means the process ended or the port is free. "Not answering" is not enough: a busy server does not answer either.
445
+ if (!D.pidAlive(pid) || await portFree(port)) { say(`✓ stopped — ${urlOf(port)}`); try { fs.unlinkSync(pidFile(port)); } catch {} return 0; }
408
446
  await sleep(250);
409
447
  }
410
448
  say(`✗ port ${port} still responding after 5s — kill it manually (pid ${pid}).`);
@@ -412,7 +450,9 @@ async function stopOne(port) {
412
450
  }
413
451
 
414
452
  async function stop() {
415
- const live = await discover();
453
+ // A busy server does not answer the probe but is still ours, and still holds its port. Stop it too.
454
+ const answering = await discover();
455
+ const live = [...answering, ...await busy(answering.map((l) => l.port))];
416
456
  if (!live.length) { say('○ not running.'); cleanStalePids([]); return 0; }
417
457
  // sessions are tmux-backed: stopping the server leaves them running; the next `pmos` re-attaches.
418
458
  say('stopping the workspace — sessions keep running in tmux and re-attach on the next launch…');
@@ -4,7 +4,7 @@
4
4
  * dimensions 1 to 5 and 7). Loads /c3.html?demo=1 at 1440 wide and then at 390 wide. Demo mode answers every /api call in the page,
5
5
  * so nothing reaches the server and the fixture (public/pm/c3-demo-data.js pmItems) is placed by the same rule the server uses.
6
6
  *
7
- * views six views in order (Inbox, Today, Upcoming, Waiting, Someday, Review), each with its count; no Triage, Week, Later
7
+ * views seven views in order (Inbox, Today, Upcoming, Waiting, Someday, Review, Done), each with its count; no Triage, Week, Later
8
8
  * lens; the right column is Focus on every view, with no Flow today (console3-deck-flow-fixes); no Jobs card, Group by job, ring or plan deck (console3-work-parity)
9
9
  * one home none of the decisions and approvals whose home is Briefing · Needs you is a Work row or Work text
10
10
  * Focus Today opens with up to three suggestions, each with the reason; Accept pins, Not today hides, Edit title saves, Unpin
@@ -12,6 +12,8 @@
12
12
  * to a later day; Done takes a one-line outcome
13
13
  * Upcoming the next 7 days by day, then Later; a deferred item says "Not before …"
14
14
  * Review one at a time with progress: Keep, Change (the edit fields), Drop, then "Review done"
15
+ * Done the last 30 days by day (Today, Yesterday, …) with the outcome and how it closed; the badge counts today; Reopen puts an
16
+ * item back in its view, with Undo, also an item that was done before the page opened (c3-work-agent amendment 18)
15
17
  * capture the preview chip names the view the line lands in, by the server's rule, and never Triage
16
18
  * phone at 390 wide every view draws with no horizontal scroll
17
19
  * The page must raise no script error. Every wait polls the page; no fixed sleep stands in for a state.
@@ -40,8 +42,8 @@ const finish = async (code) => {
40
42
  };
41
43
  process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
42
44
 
43
- const VIEWS = ['inbox', 'today', 'upcoming', 'waiting', 'someday', 'review'];
44
- const LABELS = ['Inbox', 'Today', 'Upcoming', 'Waiting', 'Someday', 'Review'];
45
+ const VIEWS = ['inbox', 'today', 'upcoming', 'waiting', 'someday', 'review', 'done'];
46
+ const LABELS = ['Inbox', 'Today', 'Upcoming', 'Waiting', 'Someday', 'Review', 'Done'];
45
47
  /* The decisions and approvals the demo used to list in Work (PRD R9): their home is Briefing · Needs you. */
46
48
  const DECISIONS = ['Approve pricing-page copy before handoff', 'Confirm the growth assumption in the Q4 forecast', 'Say yes to Sam: ship the logging change',
47
49
  'Self-serve onboarding: continue or park?', 'Pick interview slots with 3 customers', 'Assign owners to 2 unowned workshop actions',
@@ -91,9 +93,9 @@ const W = '[data-pm-work]';
91
93
  const readBar = () => st((w) => [...document.querySelectorAll(w + ' [data-pm-lenses] [data-lens]')].filter((b) => b.offsetParent !== null).map((b) => [b.getAttribute('data-lens'), b.textContent.replace(/\d+$/, '').trim()]), W);
92
94
  await until(async () => (await readBar()).length === VIEWS.length, 10000, 100);
93
95
  const bar = await readBar();
94
- ok('the bar has the six views in order: Inbox, Today, Upcoming, Waiting, Someday, Review', JSON.stringify(bar.map((b) => b[0])) === JSON.stringify(VIEWS) && JSON.stringify(bar.map((b) => b[1])) === JSON.stringify(LABELS), JSON.stringify(bar));
96
+ ok('the bar has the seven views in order: Inbox, Today, Upcoming, Waiting, Someday, Review, Done', JSON.stringify(bar.map((b) => b[0])) === JSON.stringify(VIEWS) && JSON.stringify(bar.map((b) => b[1])) === JSON.stringify(LABELS), JSON.stringify(bar));
95
97
  const c0 = await counts();
96
- ok('each view shows its count (3, 5, 6, 2, 2, 3)', JSON.stringify(VIEWS.map((v) => c0[v])) === '[3,5,6,2,2,3]', JSON.stringify(c0));
98
+ ok('each view shows its count (3, 5, 6, 2, 2, 3; Done counts today: 0)', JSON.stringify(VIEWS.map((v) => c0[v])) === '[3,5,6,2,2,3,0]', JSON.stringify(c0));
97
99
  ok('the page opens on Today', await st((w) => (document.querySelector(w + ' [data-pm-lens-head]') || {}).getAttribute && document.querySelector(w + ' [data-pm-lens-head]').getAttribute('data-pm-lens-head') === 'today', W));
98
100
  const text0 = await st((w) => document.querySelector(w).textContent, W);
99
101
  ok('no Triage, Week or Later view', !(await st((w) => !!document.querySelector(w + ' [data-lens="triage"], ' + w + ' [data-lens="week"], ' + w + ' [data-lens="later"]'), W)));
@@ -174,6 +176,28 @@ const W = '[data-pm-work]';
174
176
  if (sam) { await page.fill(W + ' ' + sam + ' [data-pm-outcome]', 'Booked for Tuesday 10:00'); await click(W + ' ' + sam + ' [data-pm-offline-done]'); }
175
177
  ok('Done with a one-line outcome closes an offline item', !!sam && !!await until(async () => !(await titles()).includes('Set up a meeting with Sam about the logging rollout') && (await counts()).upcoming === 6, 5000));
176
178
 
179
+ /* Done (c3-work-agent amendment 18): what the PM finished, by day, with how it closed; Reopen puts it back */
180
+ ok('the Done badge counts the items done today', !!await until(async () => (await counts()).done === 1, 5000), JSON.stringify(await counts()));
181
+ await view('done');
182
+ const doneRows = () => st((w) => [...document.querySelectorAll(w + ' [data-pm-list="done"]')].map((s) => [s.getAttribute('data-pm-sect'), [...s.querySelectorAll('[data-pm-done-item]')].map((r) => ({
183
+ t: (r.querySelector('[data-pm-title]') || {}).textContent, out: (r.querySelector('[data-pm-done-outcome]') || {}).textContent || '', how: (r.querySelector('[data-pm-done-how]') || {}).textContent || '',
184
+ at: (r.querySelector('[data-pm-done-at]') || {}).textContent || '', reopen: !!r.querySelector('[data-pm-reopen]') }))]), W);
185
+ const d0 = await doneRows();
186
+ ok('Done groups the items by day: Today (Sam, done just now) and Yesterday (the PRD)', JSON.stringify(d0.map((g) => [g[0], g[1].map((r) => r.t)])) === JSON.stringify([['today', ['Set up a meeting with Sam about the logging rollout']], ['yday', ['Send revised PRD to Dara']]]), JSON.stringify(d0));
187
+ const samD = d0.length ? d0[0][1][0] : {};
188
+ ok('…a row shows the outcome, how it closed, the time and Reopen', String(samD.out).trim() === 'Booked for Tuesday 10:00' && samD.how === 'You marked it done' && /^Done \d\d:\d\d$/.test(samD.at) && samD.reopen, JSON.stringify(samD));
189
+ ok('…an older row shows the day it was done', d0.length > 1 && /^Done (Tue|Wed) 7 Oct$/.test(d0[1][1][0].at), JSON.stringify(d0[1]));
190
+ ok('…with the summary line and the header', await st((w) => /^2 done in the last 7 days, 2 in 30 days\. Older items stay in their job's sheet\.$/.test((document.querySelector(w + ' [data-pm-done-sum]') || {}).textContent.trim()) && /What you finished/.test(document.querySelector(w + ' [data-pm-lens-head]').textContent), W));
191
+ await click(W + ' [data-pm-done-item] [data-pm-reopen]');
192
+ ok('Reopen says where the item went back to', !!await until(() => st(() => /Reopened\. It is back in Upcoming\./.test(document.body.textContent)), 4000));
193
+ ok('…and it leaves Done and is back in Upcoming', !!await until(async () => { const c = await counts(); return c.done === 0 && c.upcoming === 7; }, 5000), JSON.stringify(await counts()));
194
+ await toastUndo();
195
+ ok('…Undo marks it done again', !!await until(async () => (await counts()).done === 1, 5000), JSON.stringify(await counts()));
196
+ await click(W + ' [data-pm-sect="yday"] [data-pm-reopen]');
197
+ ok('Reopen works on an item done before the page opened', !!await until(async () => !(await doneRows()).some((g) => g[1].some((r) => r.t === 'Send revised PRD to Dara')), 5000), JSON.stringify(await doneRows()));
198
+ await toastUndo();
199
+ ok('…and Undo puts it back in Done', !!await until(async () => (await doneRows()).some((g) => g[1].some((r) => r.t === 'Send revised PRD to Dara')), 5000));
200
+
177
201
  /* the shared actions: Flag from the Inbox (R2) */
178
202
  await view('inbox');
179
203
  const tear = await open('Read the competitor pricing teardown');
@@ -167,7 +167,11 @@ function binaryArch(file) {
167
167
  } else {
168
168
  const want = pinned || D.DEFAULT_PORT;
169
169
  if (await free(want)) ok(`no server running; port ${want} is free (start it with: pmos)`);
170
- else {
170
+ else if (D.busyServers(STATE, { heldPorts: [want] }).length) {
171
+ const b = D.busyServers(STATE, { heldPorts: [want] })[0];
172
+ bad(`the workspace server on port ${want} (pid ${b.pid}) is running but did not answer within 2s — it is busy`,
173
+ 'wait a minute and run `pmos` again; if it still does not answer, run `pmos stop`, then `pmos`');
174
+ } else {
171
175
  const who = owner(want) || 'another program';
172
176
  bad(`no workspace server is answering, but port ${want} is held by ${who}`,
173
177
  pinned ? `stop that process, or pin a free port: PMOS_WS_PORT=7500 pmos`
@@ -69,7 +69,7 @@ Agenda shows one card, **N items need your brief**, when items wait in To brief.
69
69
 
70
70
  None of these briefs an agent or takes an item. Do that in My work.
71
71
 
72
- ## The Work page: your work in six views
72
+ ## The Work page: your work in seven views
73
73
 
74
74
  The Work page holds all the work that is yours:
75
75
 
@@ -83,7 +83,7 @@ Every action works on these rows. The first action writes the item to your own l
83
83
 
84
84
  The server decides where each item shows, so one item is never in two views.
85
85
 
86
- The six views follow the OmniFocus style. The **Work** tab badge counts Inbox plus Today.
86
+ The seven views follow the OmniFocus style. The **Work** tab badge counts Inbox plus Today.
87
87
 
88
88
  | View | What it holds |
89
89
  |---|---|
@@ -93,6 +93,7 @@ The six views follow the OmniFocus style. The **Work** tab badge counts Inbox pl
93
93
  | Waiting | Items where you wait for someone. The item that has waited longest comes first. |
94
94
  | Someday | Items you parked. They do not appear anywhere else. |
95
95
  | Review | Items in Inbox, Someday or Waiting that nobody touched for 7 days. The most stale item comes first. |
96
+ | Done | Items you marked done in the last 30 days, newest first. Each one shows the outcome and how it closed. |
96
97
 
97
98
  An item goes to the first view that fits these rules, in this order. Parked is Someday. Waiting on someone is Waiting. A defer date after today is Upcoming. Flagged is Today. Due today or earlier is Today. A later due date is Upcoming. An undated item whose defer date has arrived is Today. No date is Inbox.
98
99
 
@@ -166,6 +167,10 @@ An offline item can carry evidence that it is done. PMOS looks at your calendar
166
167
 
167
168
  Review is a weekly pass. It shows one item at a time. Press **Keep** to leave the item as it is and clear it from the queue for 7 days. Press **Change** to edit it. Press **Drop** to remove it. Any change to an item also clears it from the queue.
168
169
 
170
+ ### Done
171
+
172
+ Done lists the items you marked done in the last 30 days, in four cards: **Today**, **Yesterday**, **Earlier this week** and **Earlier, last 30 days**. Each row shows the outcome you wrote, when the item was done, and how it closed: you marked it done, you closed it from the agent draft, the agent posted it, the agent did the step you approved, or you confirmed it. Press **Reopen** to put the item back in its view. The badge on Done counts the items done today. Older items stay in their job's sheet.
173
+
169
174
  ### Link to a team task
170
175
 
171
176
  A Work item can link to the plan item it advances. The link shows on the item with the title of the plan item. Closing the plan item is a separate step, in the initiative.
@@ -176,7 +181,7 @@ A decision or an approval only you can make is not a Work item. It shows in Brie
176
181
 
177
182
  ## Using the Work page
178
183
 
179
- The Work page in the console has six views: **Inbox**, **Today**, **Upcoming**, **Waiting**, **Someday** and **Review**. Each view button shows how many items it holds.
184
+ The Work page in the console has seven views: **Inbox**, **Today**, **Upcoming**, **Waiting**, **Someday**, **Review** and **Done**. Each view button shows how many items it holds. The Done button shows how many items you finished today.
180
185
 
181
186
  Each item is in one view only. The rule, in order: an item in Someday stays in Someday. An item that waits on a person is in Waiting. An item with a Not before date after today is in Upcoming. A flagged item is in Today. An item due today or earlier is in Today. An item due later is in Upcoming. An item with no date is in Inbox.
182
187
 
@@ -17,6 +17,8 @@ const fs = require('fs');
17
17
  const path = require('path');
18
18
 
19
19
  const UNDO_MS = 8000;
20
+ /* How an item was closed (Work › Done): the PM marked it, the PM closed it from the agent draft, or the PM confirmed offline work. */
21
+ const DONE_HOW = ['you', 'agent', 'confirmed'];
20
22
  const LISTS = ['waiting', 'today', 'week', 'triage', 'later'];
21
23
  const WORK_LISTS = ['inbox', 'today', 'upcoming', 'waiting', 'someday'];
22
24
  const REVIEW_DAYS = 7;
@@ -136,9 +138,9 @@ function fold(lines) {
136
138
  else if (ev.event === 'edited' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), ...ev.fields, touched_at: ev.at });
137
139
  else if (ev.event === 'done' && items.has(ev.id)) {
138
140
  const outcome = typeof ev.outcome === 'string' && ev.outcome.trim() ? { outcome: ev.outcome.trim() } : {};
139
- items.set(ev.id, { ...items.get(ev.id), done: true, done_at: ev.at, touched_at: ev.at, ...outcome });
141
+ items.set(ev.id, { ...items.get(ev.id), done: true, done_at: ev.at, done_how: DONE_HOW.includes(ev.how) ? ev.how : 'you', touched_at: ev.at, ...outcome });
140
142
  }
141
- else if (ev.event === 'reopened' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), done: false, done_at: null, touched_at: ev.at });
143
+ else if (ev.event === 'reopened' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), done: false, done_at: null, done_how: null, touched_at: ev.at });
142
144
  else if (ev.event === 'kept' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), touched_at: ev.at });
143
145
  else if (ev.event === 'dropped' || ev.event === 'deleted') items.delete(ev.id);
144
146
  }
@@ -177,4 +179,4 @@ function readLines(file) {
177
179
  return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } });
178
180
  }
179
181
 
180
- module.exports = { UNDO_MS, LISTS, WORK_LISTS, REVIEW_DAYS, workPlacement, sortWorkList, reviewQueue, arrivesOn, parseLine, placement, fold, removalEvent, undoEvent, canUndo, appendEvent, readLines };
182
+ module.exports = { UNDO_MS, DONE_HOW, LISTS, WORK_LISTS, REVIEW_DAYS, workPlacement, sortWorkList, reviewQueue, arrivesOn, parseLine, placement, fold, removalEvent, undoEvent, canUndo, appendEvent, readLines };
@@ -13,7 +13,7 @@
13
13
  * defer, flagged, resolves, kind, person, evidence, outcome
14
14
  * POST /api/pm-items/remove { id } → { ok, undo_ms } deleted (created here) or dropped (imported)
15
15
  * POST /api/pm-items/undo { id } → { ok, item } | 409 restores the last removal inside the 8 s window
16
- * POST /api/pm-items/done { id, outcome? } → { ok } folds to item.done + done_at (+ outcome, one line); /reopen undoes it
16
+ * POST /api/pm-items/done { id, outcome?, how? } → { ok } folds to item.done + done_at (+ outcome, one line; + done_how: you|agent|confirmed); /reopen undoes it
17
17
  * POST /api/pm-items/park { id } → { ok } parked: true, placed in Later; /unpark clears it
18
18
  * POST /api/pm-items/flag { id, flagged } → { ok } flagged: boolean; a flagged item is in Today
19
19
  * POST /api/pm-items/defer { id, until|null } → { ok } defer: not before that date (null clears); clears parked
@@ -193,7 +193,8 @@ function flagItem(body, itemLines, events) {
193
193
  function doneItem(b, l) {
194
194
  if (b.outcome !== undefined && b.outcome !== null && typeof b.outcome !== 'string') return { status: 400, body: { ok: false, error: 'outcome must be a string' } };
195
195
  const outcome = typeof b.outcome === 'string' && b.outcome.trim() ? { outcome: b.outcome.trim() } : {};
196
- return flagItem(b, l, [{ event: 'done', id: b.id, ...outcome }]);
196
+ const how = pmItems.DONE_HOW.includes(b.how) ? { how: b.how } : {}; // how it closed, for Work › Done (c3-work-agent amendment 18)
197
+ return flagItem(b, l, [{ event: 'done', id: b.id, ...outcome, ...how }]);
197
198
  }
198
199
  const reopenItem = (b, l) => flagItem(b, l, [{ event: 'reopened', id: b.id }]);
199
200
  const parkItem = (b, l) => flagItem(b, l, [{ event: 'edited', id: b.id, fields: { parked: true, list_override: null } }]);
@@ -102,6 +102,21 @@ function stalePidFiles(stateDir, { livePorts = [], alive = pidAlive } = {}) {
102
102
  .map((r) => r.file);
103
103
  }
104
104
 
105
+ /**
106
+ * The workspace servers that are running but did not answer: a record whose port is not among the
107
+ * live ones, whose port is still held, and whose pid is alive. A busy server (a long synchronous job,
108
+ * or a slow first start on Windows) looks exactly like this. Reading it as "something else holds the
109
+ * port" is how `pmos` started a second server beside a first one that was only slow. `heldPorts` is
110
+ * injected (the caller owns the socket), so this stays pure.
111
+ */
112
+ function busyServers(stateDir, { livePorts = [], heldPorts = [], alive = pidAlive } = {}) {
113
+ const live = new Set(livePorts.map(Number));
114
+ const held = new Set(heldPorts.map(Number));
115
+ return pidRecords(stateDir)
116
+ .filter((r) => !live.has(r.port) && held.has(r.port) && alive(r.pid))
117
+ .map((r) => ({ port: r.port, pid: r.pid }));
118
+ }
119
+
105
120
  /**
106
121
  * The first port at or after `start` that `isFree` accepts, within the bounded span. `isFree` is
107
122
  * injected (the caller owns the socket), so this stays pure and the span is assertable.
@@ -123,5 +138,5 @@ function looksLikeWorkspace(body) {
123
138
 
124
139
  module.exports = {
125
140
  DEFAULT_PORT, FALLBACK_SPAN, PID_RE,
126
- pidFileName, pidRecords, candidatePorts, pidAlive, stalePidFiles, nextFreePort, looksLikeWorkspace,
141
+ pidFileName, pidRecords, candidatePorts, pidAlive, stalePidFiles, busyServers, nextFreePort, looksLikeWorkspace,
127
142
  };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.231.0",
3
+ "version": "0.233.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.231.0",
9
+ "version": "0.233.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.231.0",
3
+ "version": "0.233.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",
@@ -387,7 +387,7 @@ const W_KEYS = it('Payments sandbox keys', { waiting_on: 'Dara', due: '2026-10-0
387
387
  const W_LABEL = it('Annual-plan label', { waiting_on: 'Lee', due: '2026-10-07', job: 'j1', priority: 'P3', touched_at: at('2026-09-29') });
388
388
  const W_JIRA = it('Clean up the Checkout Jira labels', { parked: true, created_at: '2026-09-10T09:00:00.000Z', touched_at: at('2026-09-18') });
389
389
  const W_NOMEET = it('Try a no-meeting Wednesday', { parked: true, touched_at: at('2026-10-02') });
390
- /* done yesterday: counts in its job, never drawn */
390
+ /* done yesterday: counts in its job, and Work › Done lists it under Yesterday */
391
391
  const W_PRD = it('Send revised PRD to Dara', { due: '2026-10-07', job: 'j1', done: true, done_at: '2026-10-07T17:10:00.000Z' });
392
392
  const ITEMS = [W_TIMELINE, W_QA, W_CALL, W_RISK, W_PAGER, W_SAM, W_SSO, W_NOTES, W_SALES, W_TAGS, W_Q1, W_TEAR, W_BOOK, W_IDEA, W_KEYS, W_LABEL, W_JIRA, W_NOMEET, W_PRD];
393
393
  /* Focus: up to three jobs for today, each with the reason (rules first: an overdue promise, an offline item due, a flag) */
@@ -2039,6 +2039,10 @@
2039
2039
  }
2040
2040
 
2041
2041
  .c3 .wk-sm{font-size:var(--fs-2)}
2042
+ /* Work › Done (c3-work-agent amendment 18): the outcome line under the title, and the summary above the groups */
2043
+ .c3 .wk-done-out{display:flex;align-items:center;gap:4px;color:var(--foreground);font-size:var(--fs-2)}
2044
+ .c3 .wk-done-out .ic{color:var(--muted-foreground)}
2045
+ .c3 .wk-done-sum{margin-bottom:12px}
2042
2046
  .c3 .wk-bar{appearance:none;-webkit-appearance:none;width:100%;height:8px;border:0;border-radius:6px;overflow:hidden;background:var(--muted)}
2043
2047
  .c3 .wk-bar::-webkit-progress-bar{background:var(--muted)}
2044
2048
  .c3 .wk-bar::-webkit-progress-value{background:var(--acc);border-radius:6px}
@@ -110,9 +110,11 @@ export function c3ib(label, icon, attrs, cls) {
110
110
 
111
111
  const API = '/api/pm-items';
112
112
  const JOBS = '/api/pm-jobs';
113
- /* The six views (initiative console3-work-page, PRD R1): five places an open item can be, and the weekly Review pass. */
114
- const LENSES = [['inbox', 'Inbox'], ['today', 'Today'], ['upcoming', 'Upcoming'], ['waiting', 'Waiting'], ['someday', 'Someday'], ['review', 'Review']];
115
- const WLI = { inbox: 'inbox', today: 'sun', upcoming: 'cal', waiting: 'wait', someday: 'moon', review: 'check' };
113
+ /* The seven views (initiative console3-work-page, PRD R1): five places an open item can be, the weekly Review pass, and Done:
114
+ * what the PM finished in the last 30 days (c3-work-agent amendment 18). */
115
+ const LENSES = [['inbox', 'Inbox'], ['today', 'Today'], ['upcoming', 'Upcoming'], ['waiting', 'Waiting'], ['someday', 'Someday'], ['review', 'Review'], ['done', 'Done']];
116
+ const WLI = { inbox: 'inbox', today: 'sun', upcoming: 'cal', waiting: 'wait', someday: 'moon', review: 'check', done: 'todo' };
117
+ const DONE_DAYS = 30;
116
118
  const WHO = ['you', 'agent-drafts', 'agent-does'];
117
119
  const OUTWARD = /\b(send|sends|sending|email|emails|message|messages|tell|post|reply to)\b/i; // mirrors lib/item-steps.js
118
120
  const RESOLVES_TIP = 'Your work. The team task is separate: close it in its initiative.';
@@ -135,6 +137,7 @@ export const COPY = Object.freeze({
135
137
  lens: {
136
138
  inbox: 'Decide when or whether', today: 'Overdue, due today and flagged', upcoming: 'The next 7 days, then anything later',
137
139
  waiting: 'Things you handed to someone', someday: 'Parked on purpose', review: 'A weekly pass over items without a date',
140
+ done: 'What you finished',
138
141
  },
139
142
  lensSub: {
140
143
  inbox: 'Nothing here has a date. Tap Decide to give each item a day, park it or drop it.',
@@ -143,6 +146,7 @@ export const COPY = Object.freeze({
143
146
  waiting: 'You expect these back. Nudge when one goes quiet.',
144
147
  someday: 'Nothing here asks for attention until the weekly Review.',
145
148
  review: 'Items in Inbox, Waiting or Someday that nobody touched for 7 days, most stale first. One at a time: keep it, change it, or drop it.',
149
+ done: 'The last 30 days, newest first, with the outcome you wrote. Reopen puts an item back where it was.',
146
150
  },
147
151
  sect: { inbox: 'Inbox', waiting: 'Waiting', someday: 'Someday', overdue: 'Overdue', dueToday: 'Due today', flagged: 'Flagged', next7: 'Next 7 days', later: 'Later' },
148
152
  decisions: (n) => ` ${n} decision${n === 1 ? '' : 's'} wait${n === 1 ? 's' : ''} in Briefing, Needs you.`, openBriefing: 'Open Briefing',
@@ -153,7 +157,13 @@ export const COPY = Object.freeze({
153
157
  emptyView: {
154
158
  inbox: 'Inbox is empty. Capture anything that crosses your mind.', today: 'Nothing due, overdue or flagged. Pick from Upcoming when you are ready.',
155
159
  upcoming: 'Nothing dated in the days ahead.', waiting: 'Nobody owes you anything right now.', someday: 'Nothing parked.',
160
+ done: 'Nothing done in the last 30 days. Items you mark done land here.',
156
161
  },
162
+ /* Done (c3-work-agent amendment 18) */
163
+ doneSum: (w, m) => `${w} done in the last 7 days, ${m} in 30 days. Older items stay in their job's sheet.`,
164
+ doneSect: { today: 'Today', yday: 'Yesterday', week: 'Earlier this week', older: 'Earlier, last 30 days' }, doneLegend: 'Reopen puts it back',
165
+ doneAt: (t) => 'Done ' + t, reopen: 'Reopen', backTo: (v) => 'Back to ' + v, reopened: (v) => `Reopened. It is back in ${v}.`,
166
+ how: { you: 'You marked it done', agent: 'Closed from the agent draft', posted: 'Agent posted it', step: 'Agent did the step you approved', confirmed: 'You confirmed it' },
157
167
  done: 'Done', doneToast: 'Marked done.', steps: 'Steps', noSteps: 'No steps yet.', addStep: 'Add a step',
158
168
  who: { you: 'You', 'agent-drafts': 'Agent drafts', 'agent-does': 'Agent does' },
159
169
  whoOutward: 'Sends to a person: the agent drafts, you slide to send.', options: 'Options', plan: 'Plan the steps',
@@ -194,8 +204,9 @@ function applyOv(base, ov) {
194
204
  const jobOfItem = {};
195
205
  const wasDone = new Set((d.items || []).filter((i) => i.done).map((i) => i.id));
196
206
  const items = [...ov.added.slice().reverse(), ...(d.items || [])].filter((i) => !ov.gone.has(i.id)).map((i) => {
197
- const m = { ...i, ...(ov.edits.get(i.id) || {}) };
198
- m.done = !!(i.done || ov.done.has(i.id));
207
+ const e = ov.edits.get(i.id) || {};
208
+ const m = { ...i, ...e };
209
+ m.done = e.done !== undefined ? !!e.done : !!(i.done || ov.done.has(i.id)); // a reopen (done: false) also reopens a fixture's done item
199
210
  m.steps = [...(i.steps || []), ...(ov.steps.get(i.id) || [])].map((s) => (ov.who.has(s.id) ? { ...s, who: ov.who.get(s.id) } : s));
200
211
  jobOfItem[m.id] = m.job;
201
212
  return m;
@@ -229,8 +240,8 @@ function applyLocal(inst, path, body, r) {
229
240
  const now = new Date().toISOString();
230
241
  const edit = (f) => ov.edits.set(id, { ...(ov.edits.get(id) || {}), ...(f || {}), touched_at: now }); // the server stamps touched_at on every event
231
242
  switch (path.replace(API, '').replace(JOBS, '/job')) {
232
- case '/done': ov.done.add(id); edit(body.outcome ? { outcome: body.outcome } : {}); break;
233
- case '/reopen': ov.done.delete(id); edit(); break;
243
+ case '/done': ov.done.add(id); edit({ done: true, done_at: new Date(today + 'T' + new Date().toTimeString().slice(0, 8)).toISOString(), done_how: body.how || 'you', ...(body.outcome ? { outcome: body.outcome } : {}) }); break; // done_at: now, on the fixture's day
244
+ case '/reopen': ov.done.delete(id); edit({ done: false, done_at: null, done_how: null }); break;
234
245
  case '/park': case '/someday': edit({ parked: true }); break;
235
246
  case '/unpark': edit({ parked: false }); break;
236
247
  case '/edit': edit(body.fields); break;
@@ -362,7 +373,8 @@ const undoBy = (inst, route, body) => async () => !!(await press(inst, API + rou
362
373
  /* Does the change move the item out of the view on screen? Then its row leaves; otherwise it stays and redraws. */
363
374
  const leaves = (inst, item, patch) => inst.lens === 'review' || workPlace({ ...item, ...patch }, inst.data.today) !== inst.lens;
364
375
 
365
- const doneAct = (inst, item, row, text, outcome) => act(inst, row, { route: '/done', body: outcome ? { id: item.id, outcome } : { id: item.id }, text: text || COPY.doneToast, undo: undoBy(inst, '/reopen', { id: item.id }) });
376
+ /* `how` says how it closed, for the Done view: 'you' (the default), 'agent' (from the agent draft) or 'confirmed' (offline work the PM confirmed). */
377
+ const doneAct = (inst, item, row, text, outcome, how) => act(inst, row, { route: '/done', body: { id: item.id, ...(outcome ? { outcome } : {}), ...(how ? { how } : {}) }, text: text || COPY.doneToast, undo: undoBy(inst, '/reopen', { id: item.id }) });
366
378
  const dropAct = (inst, item, row) => act(inst, row, { route: '/remove', body: { id: item.id }, text: COPY.removed, undo: undoBy(inst, '/undo', { id: item.id }) });
367
379
  const todayAct = (inst, item, row) => act(inst, row, { route: '/edit', body: { id: item.id, fields: { due: inst.data.today, defer: null } }, text: 'In Today.',
368
380
  undo: undoBy(inst, '/edit', { id: item.id, fields: { due: item.due || null, defer: item.defer || null } }), leave: leaves(inst, item, { due: inst.data.today, defer: null }) });
@@ -490,7 +502,10 @@ function views(inst) {
490
502
  return { inbox: L.triage || [], today: L.today || [], upcoming: [...(L.week || []), ...(L.later || []).filter((i) => !i.parked)], waiting: L.waiting || [], someday: (L.later || []).filter((i) => i.parked) };
491
503
  }
492
504
  const shown = (inst, id) => ((id === 'review' ? (inst.data || {}).review : views(inst)[id]) || []).filter((i) => !inst.hidden.has(i.id) && !i.done);
493
- const countOf = (inst, id) => { const c = (inst.data || {}).counts; return c && typeof c[id] === 'number' ? c[id] : shown(inst, id).length; };
505
+ const countOf = (inst, id) => {
506
+ if (id === 'done') return doneItems(inst).filter((i) => doneAgo(inst, i) <= 0).length; // the Done badge: items done today
507
+ const c = (inst.data || {}).counts; return c && typeof c[id] === 'number' ? c[id] : shown(inst, id).length;
508
+ };
494
509
  const itemById = (inst, id) => ((inst.data && inst.data.items) || []).find((i) => i.id === id)
495
510
  || [...VIEWS.map((v) => views(inst)[v] || []).flat(), ...((inst.data && inst.data.review) || [])].find((i) => i.id === id);
496
511
  /* The title of the team task a Work item advances (PRD R8): the item's own copy, then a map the answer may carry, else the id. */
@@ -589,7 +604,7 @@ function offlineBlock(inst, item) {
589
604
  return el('div', { class: 'wkoff', 'data-pm-offline': item.id },
590
605
  el('p', { class: 'wkoff-n' }, c3ic('user', 'sm'), COPY.offlineNote(item.person)),
591
606
  item.evidence ? el('div', { class: 'wkev', 'data-pm-evidence': item.id }, c3ic('eye', 'sm'), el('span', { class: 'wk-min' }, el('b', null, COPY.looksDone + ' '), item.evidence),
592
- pill(COPY.confirm, 'check', { class: 'pill pri', 'data-pm-confirm': item.id, onclick: () => doneAct(inst, item, rowOf(inst, item), 'Done. You confirmed it.') })) : null,
607
+ pill(COPY.confirm, 'check', { class: 'pill pri', 'data-pm-confirm': item.id, onclick: () => doneAct(inst, item, rowOf(inst, item), 'Done. You confirmed it.', undefined, 'confirmed') })) : null,
593
608
  el('div', { class: 'wkq' }, el('span', { class: 'form wkoff-o' }, out), pill(COPY.done, 'check', { class: 'pill pri', 'data-pm-offline-done': item.id, onclick: doneNow })),
594
609
  el('div', { class: 'wkq' }, el('span', { class: 'dim mono' }, COPY.notYet),
595
610
  ...[['later-today', COPY.laterToday], ['tomorrow', COPY.tomorrow], ['next-week', COPY.nextWeek]].map(([w, l]) => pill(l, null, { 'data-pm-notyet': w, onclick: () => notYetAct(inst, item, rowOf(inst, item), w) })),
@@ -807,10 +822,10 @@ function agentCase(inst, item, L) {
807
822
  // a step in another system replaces the post: the approved send does the step (amendment 17)
808
823
  step ? o('step', AG.doIt(step), [fx('lock', AG.stepFx1, 'warn'), fx('out', AG.stepFx2, 'warn')], () => agentPress(inst, '/api/my-work/decide', { item: item.id, action: 'approve' }, AG.doing), { outward: true }) : null,
809
824
  mine || step ? null : o('post', AG.post(k), [fx('lock', 'Outward: exactly this text', 'warn'), fx('out', 'Goes to ' + w + ' as you', 'warn')], () => agentPress(inst, '/api/my-work/decide', { item: item.id, action: 'approve' }, AG.posting), { outward: true }),
810
- mine ? o('done', AG.doneIt, [fx('check', AG.doneFx, 'ok')], (r) => doneAct(inst, item, r, AG.doneToast)) : null,
825
+ mine ? o('done', AG.doneIt, [fx('check', AG.doneFx, 'ok')], (r) => doneAct(inst, item, r, AG.doneToast, undefined, 'agent')) : null,
811
826
  o('edit', AG.editDraft, [fx('edit', step ? 'The step then uses your version' : mine ? 'Your version replaces the draft here' : 'Post then sends your version', 'ok')], () => openDraft(item, inst, true)),
812
827
  o('keep', AG.keepDraft, [fx('check', 'Nothing leaves PMOS', 'ok')], () => say({ text: mine ? AG.keptMine : AG.kept, timeout: 3000 })),
813
- mine ? null : o('done', AG.doneIt, [fx('check', AG.doneFx, 'ok')], (r) => doneAct(inst, item, r, AG.doneToast)),
828
+ mine ? null : o('done', AG.doneIt, [fx('check', AG.doneFx, 'ok')], (r) => doneAct(inst, item, r, AG.doneToast, undefined, 'agent')),
814
829
  ].filter(Boolean);
815
830
  extras.push(draftCard(inst, item, k, false), conversation(inst, item, false, w));
816
831
  before = el('div', { class: 'wk-conv-h', 'data-pm-agent-decide': item.id }, el('span', { class: 'dim mono' }, c3ic('check', 'sm'), AG.orDecide));
@@ -1376,6 +1391,71 @@ function openNewJob(inst) {
1376
1391
  later(inst, () => input.focus(), 30);
1377
1392
  }
1378
1393
 
1394
+ /* ── Done (c3-work-agent amendment 18): what the PM finished in the last 30 days, by day, with how it closed and Reopen ── */
1395
+
1396
+ const localDay = (iso) => { const d = new Date(iso); return Number.isNaN(d.getTime()) ? '' : d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
1397
+ /* Days between the day an item was done and today: 0 = today. */
1398
+ const doneAgo = (inst, i) => daysBetween(localDay(i.done_at), inst.data.today);
1399
+ function doneItems(inst) {
1400
+ if (!inst.data) return [];
1401
+ return (inst.data.items || []).filter((i) => i.done && i.done_at && localDay(i.done_at) && doneAgo(inst, i) <= DONE_DAYS && !inst.hidden.has(i.id))
1402
+ .sort((a, b) => String(b.done_at).localeCompare(String(a.done_at)));
1403
+ }
1404
+ /* How it closed: what the agent did wins (it posted, or did the approved step), else what the done press said. */
1405
+ const DONE_HOW_IC = { you: 'check', agent: 'spark', posted: 'out', step: 'spark', confirmed: 'eye' };
1406
+ function howOf(item) {
1407
+ const a = item.agent;
1408
+ if (a && a.state === 'sent') return a.draft && a.draft.step ? 'step' : 'posted';
1409
+ return DONE_HOW_IC[item.done_how] ? item.done_how : 'you';
1410
+ }
1411
+
1412
+ function doneRow(inst, item) {
1413
+ const today = inst.data.today;
1414
+ const how = howOf(item);
1415
+ const was = workPlace({ ...item, done: false }, today);
1416
+ const chips = [el('span', { class: 'st ok', 'data-pm-done-at': item.id }, c3ic('check', 'sm'), COPY.doneAt(doneAgo(inst, item) <= 0 ? hhmm(item.done_at) : dayLabel(localDay(item.done_at)))),
1417
+ el('span', { class: 'chip2', 'data-pm-done-how': how }, c3ic(DONE_HOW_IC[how], 'sm'), COPY.how[how])];
1418
+ if (item.origin === 'jira' || item.origin === 'wbs') {
1419
+ const label = (item.origin === 'jira' ? 'Jira ' + (item.key || '') : 'WBS' + (item.key ? ' ' + item.key : '')).trim();
1420
+ chips.push(item.url ? el('a', { class: 'tag', href: item.url, target: '_blank', rel: 'noopener' }, label) : el('span', { class: 'tag' }, label));
1421
+ } else if (item.origin === 'brief') chips.push(el('span', { class: 'tag' }, COPY.suggested));
1422
+ if (item.kind === 'offline' && item.person) chips.push(el('span', { class: 'chip2' }, c3ic('user', 'sm'), item.person));
1423
+ if (item.resolves) chips.push(el('span', { class: 'chip2', title: RESOLVES_TIP }, c3ic('init', 'sm'), resolvesTitle(inst, item)));
1424
+ const row = el('li', { class: 'nrow wrow wk-done-row', 'data-pm-done-item': item.id });
1425
+ const reopen = pill(COPY.reopen, 'undo', { 'data-pm-reopen': item.id, title: COPY.backTo(labelOf(was)),
1426
+ onclick: (e) => { e.stopPropagation(); act(inst, row, { route: '/reopen', body: { id: item.id }, text: COPY.reopened(labelOf(was)), undo: undoBy(inst, '/done', { id: item.id, ...(item.done_how ? { how: item.done_how } : {}) }) }); } });
1427
+ row.append(el('span', { class: 'ki' }, c3ic('check')),
1428
+ el('div', { class: 'nb' }, el('span', { class: 'tt', 'data-pm-title': item.id }, item.title),
1429
+ item.outcome ? el('div', { class: 'wk-done-out', 'data-pm-done-outcome': item.id }, c3ic('reply', 'sm'), item.outcome) : null,
1430
+ el('div', { class: 'chips' }, chips)),
1431
+ el('div', { class: 'acts' }, reopen));
1432
+ return row;
1433
+ }
1434
+
1435
+ function doneMain(inst) {
1436
+ const all = doneItems(inst);
1437
+ if (!all.length) {
1438
+ const sec = sect(el('div', { class: 'sh2' }, el('h2', null, c3ic('todo', 'sm'), labelOf('done'), ' ', el('span', { class: 'bdg g' }, '0'))),
1439
+ el('ul', { class: 'list' }, el('li', { class: 'fold', 'data-pm-done-empty': '' }, c3ic('check', 'sm'), COPY.emptyView.done)));
1440
+ sec.setAttribute('data-pm-list', 'done');
1441
+ return [sec];
1442
+ }
1443
+ const g = { today: [], yday: [], week: [], older: [] };
1444
+ all.forEach((i) => { const n = doneAgo(inst, i); (n <= 0 ? g.today : n === 1 ? g.yday : n <= 7 ? g.week : g.older).push(i); });
1445
+ const week = all.filter((i) => doneAgo(inst, i) <= 7).length;
1446
+ const out = [el('div', { class: 'fold wk-done-sum', 'data-pm-done-sum': '' }, c3ic('check', 'sm'), COPY.doneSum(week, all.length))];
1447
+ let first = true;
1448
+ [['today', 'sun'], ['yday', 'clock'], ['week', 'cal'], ['older', 'moon']].filter(([k]) => g[k].length).forEach(([k, ic]) => {
1449
+ const sec = sect(el('div', { class: 'sh2' }, el('h2', null, c3ic(ic, 'sm'), COPY.doneSect[k], ' ', el('span', { class: 'bdg g' }, String(g[k].length))),
1450
+ first ? el('span', { class: 'dim wk-sm' }, COPY.doneLegend) : null),
1451
+ el('ul', { class: 'list', role: 'list' }, g[k].map((i) => doneRow(inst, i))));
1452
+ first = false;
1453
+ sec.setAttribute('data-pm-list', 'done'); sec.setAttribute('data-pm-sect', k);
1454
+ out.push(sec);
1455
+ });
1456
+ return out;
1457
+ }
1458
+
1379
1459
  /* ── the page ────────────────────────────────────────────────────────────────────────────────────────────────────── */
1380
1460
 
1381
1461
  /* The mockup's workHead() calbar: the .seg view bar with a count on every view (line 3281). */
@@ -1436,6 +1516,7 @@ function draw(inst) {
1436
1516
  if (lens === 'review') main = [reviewMain(inst), inst.draftsHost];
1437
1517
  else if (lens === 'upcoming') main = upcomingMain(inst, list);
1438
1518
  else if (lens === 'today') main = todayMain(inst, list);
1519
+ else if (lens === 'done') main = doneMain(inst);
1439
1520
  else {
1440
1521
  const sec = sect(el('div', { class: 'sh2' }, el('h2', null, c3ic(WLI[lens], 'sm'), COPY.sect[lens], ' ', el('span', { class: 'bdg g' }, String(list.length))), legendEl()),
1441
1522
  el('ul', { class: 'list', role: 'list' }, list.length ? list.map((i) => itemRow(inst, i, lens)) : empty));
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "one-line-setup: on Windows the background server hides the console window of every command it runs (lib/win-hide.js).",
3
- "modules": ["win-hide.test.js"],
4
- "seconds": { "win-hide.test.js": 1 }
2
+ "_comment": "one-line-setup: on Windows the background server hides the console window of every command it runs (lib/win-hide.js); `pmos` waits for its own busy server instead of starting a second one (busy-server.test.js).",
3
+ "modules": ["win-hide.test.js", "busy-server.test.js"],
4
+ "seconds": { "win-hide.test.js": 1, "busy-server.test.js": 12 }
5
5
  }