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 +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/bin/pmos.js +46 -6
- package/workspace/c3-work-test.js +29 -5
- package/workspace/doctor.js +5 -1
- package/workspace/guide/my-work.md +8 -3
- package/workspace/lib/pm-items.js +5 -3
- package/workspace/lib/routes-pm-items.js +3 -2
- package/workspace/lib/server-discovery.js +16 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-data.js +1 -1
- package/workspace/public/pm/console3.css +4 -0
- package/workspace/public/pm/pm-work-view.js +93 -12
- package/workspace/test-lists/one-line-setup.json +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "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": {
|
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-
|
|
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-
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-14T09:17:36Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/bin/pmos.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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');
|
package/workspace/doctor.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 }
|
|
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
|
-
|
|
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.
|
|
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.
|
|
9
|
+
"version": "0.233.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -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,
|
|
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
|
|
114
|
-
|
|
115
|
-
const
|
|
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
|
|
198
|
-
m
|
|
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
|
-
|
|
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) => {
|
|
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
|
}
|