create-pmos 0.125.0 → 0.127.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.125.0",
3
+ "version": "0.127.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@d18afe7
1
+ kernel-source: pmos@ad6b0f4
2
2
  assembled: 2026-10-11
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-11T02:26:05Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-11T02:52:51Z"}
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-11T02:26:05Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-11T02:52:51Z"}
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-11T02:26:05Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-11T02:52:51Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -0,0 +1,40 @@
1
+ ---
2
+ type: Guide
3
+ title: "Briefing: cards, time of day, Needs you and Portfolio pulse"
4
+ description: "How the Briefing cards keep one height and flip in place, how to confirm an act on a large screen, how the day part follows the clock, how a Portfolio pulse row opens inline, and how overdue items reach Needs you."
5
+ timestamp: 2026-10-11
6
+ ---
7
+
8
+ # Briefing: cards, time of day, Needs you and Portfolio pulse
9
+
10
+ ## Cards
11
+
12
+ Every card in a deck has the same height. Text that does not fit is cut short. Press a card to turn it over. The card turns in place in about a third of a second. No other card moves, and nothing below the deck moves. The back has the same size as the front and scrolls inside when it is long. With reduced motion on, the card changes at once.
13
+
14
+ The icon of a card comes from fixed rules over the kind of the card. A kind the rules do not know gets a neutral icon.
15
+
16
+ ## Confirm an act
17
+
18
+ On a screen with a mouse or trackpad, an act shows a compact button.
19
+
20
+ - An act that sends something out, or that cannot be undone, asks you to press and hold. A ring fills for 0.6 seconds, and the act runs when it is full.
21
+ - Any other act runs on one click.
22
+ - With the keyboard, press Enter or Space on the button, then choose **Confirm** or **Cancel**. Escape closes the step.
23
+
24
+ On a touch screen the slide stays. It is at most 320 pixels wide.
25
+
26
+ ## Time of day
27
+
28
+ There is no selector for the part of the day. The Briefing reads the clock and checks it every five minutes. When the part of the day changes, the cards are ranked again only after you have not touched the page for a minute and no card is open, or the next time the page loads. A thin strip in the top bar shows the sun or the moon on the day's arc, with sunrise and sunset. It is worked out on this machine from the date and the time zone. It makes no network request.
29
+
30
+ ## Portfolio pulse
31
+
32
+ Press a row of the Portfolio pulse to see it open under the row, on the Briefing. The tab does not change. The open row shows the promised date, the forecast and the slip. Press **Open initiative** inside it to go to the Initiatives tab.
33
+
34
+ ## Needs you
35
+
36
+ Needs you also lists your own items that are overdue or due today. Overdue items come first, the most overdue first, then items due today. A row says how many days overdue it is. Done items, parked items, items waiting on someone and items without a date are not listed. At most five show.
37
+
38
+ ## Work
39
+
40
+ The Work page no longer shows **Flow today**. **Jobs to be done** stays.
@@ -26,6 +26,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
26
26
  | The initiatives of a repository, or of every repository | Initiatives. Choose **All repositories** in Repository ▾ for every repository. |
27
27
  | The sessions of a repository, by initiative | The agent dock. ⌘K → **Branches and sessions** lists the branches. |
28
28
  | The items of an initiative | Board, or Breakdown in the initiative detail |
29
+ | How the Briefing cards, day part, Portfolio pulse and overdue items work | [Briefing](briefing-cards.md) |
29
30
  | Run reports | Reports |
30
31
  | Whether a document is the same as its Google Docs or Confluence copy | Docs: ⌘K → **Docs**, or press `g d` |
31
32
  | The people you work with: who they are, your interactions, their tone, your notes and shared work | People: ⌘K → **People**, or press `g p` |
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Guide: layout"
4
4
  description: "The frame: one top bar, the place on screen, the detail and the agent dock; the phone tabs and lists; the keys; and the older layout."
5
- timestamp: 2026-10-08
5
+ timestamp: 2026-10-11
6
6
  ---
7
7
 
8
8
  # Guide: layout
@@ -51,7 +51,7 @@ The dock holds your terminals, documents, conversations, skills and web pages. I
51
51
 
52
52
  - Press ⌘J (Ctrl-J), or the button on the rail, to open the dock. Press ⌘J again, or the fold button in the dock, to fold it to the rail.
53
53
  - The dock never opens by itself. A tab that opens while the dock is a rail waits in the dock until you open it.
54
- - The dock lists the sessions of the repository by initiative. A session is in the group of the initiative it is recorded on, or of its `initiative/<id>` branch. Then come **Not linked** (no initiative) and **Automatic** (anything you did not start yourself, for example a schedule, the day run, a note's summary or bullets, or a Jira push). **Automatic** is hidden until you press **Show automatic** at the foot of the list (or beside the group select in the strip). A session in front, or one held on a permission question, is always shown. An agent that has finished and reads "needs you" is hidden like the rest. Finished automatic sessions remove themselves a minute after a clean exit, and one with no output for 60 minutes is ended and removed. A failed one stays. When a session has been removed, its terminal tab closes too, also when the workspace was stopped for an update, so the tab does not come back as a row under **Open**. The setting is in `state/session-policy.json`: `finish` (true or false) and `idleMinutes` (5 or more, or 0 for off). Then **Open** lists the tabs that are not sessions, for example documents and skills. Each one shows its title, what it is (for example PRD, Document or Web page) and its branch when it is not main. **✕** on an Open row closes the tab only. A terminal keeps running.
54
+ - The dock lists the sessions of the repository by initiative. A session is in the group of the initiative it is recorded on, or of its `initiative/<id>` branch. Then come **Not linked** (no initiative) and **Automatic** (anything you did not start yourself, for example a schedule, the day run, a note's summary or bullets, or a Jira push). **Automatic** is hidden until you press **Show automatic** at the foot of the list (or beside the group select in the strip). A session in front, or one held on a permission question, is always shown. An agent that has finished and reads "needs you" is hidden like the rest. Finished automatic sessions remove themselves a minute after a clean exit, and one with no output for 60 minutes is ended and removed. A failed one stays for a day. When a schedule starts a new run, it ends its previous run first, unless that run is still working or waits for your answer. Any session that has ended, also one you started, is removed after 7 days. Its conversation is kept when it ends. A session you started is never ended for you. When a session has been removed, its terminal tab closes too, also when the workspace was stopped for an update, so the tab does not come back as a row under **Open**. The setting is in `state/session-policy.json`: `finish` (true or false), `idleMinutes` (5 or more, or 0 for off) and `keepExitedDays` (1 to 365, or 0 to keep ended sessions). Then **Open** lists the tabs that are not sessions, for example documents and skills. Each one shows its title, what it is (for example PRD, Document or Web page) and its branch when it is not main. **✕** on an Open row closes the tab only. A terminal keeps running.
55
55
  - Each session row starts with a mark for its state: ● working, ◼ quiet, ◆ needs you, ◇ maybe blocked, ○ ended. Under the name, the row shows the state when the session is not working, the type of work when one is set, and the branch when it is not main and not the initiative's own branch. Running sessions come first and ended sessions last. Press the row to bring the session to the front.
56
56
  - Press the name of a group to fold it or open it. The number beside it counts its sessions. **Automatic** is folded while none of its sessions is running, because each run leaves an ended session. The group of the session in front always stays open, and the workspace keeps your choice for each group.
57
57
  - **⋯** and **✕** show on the row in front, on the row under the pointer and on the row with the keyboard focus. On a touch screen they always show.
@@ -251,6 +251,24 @@ function removeWorktree(root, dir) {
251
251
  return { ok: true, dir: target.dir, branch: target.branch, pruned: false };
252
252
  }
253
253
 
254
+ // { ok, current, detached } — only the HEAD half of branchInfo, with the same failure codes. The
255
+ // workspace list and the session heartbeat need the branch name and nothing else; branchInfo also
256
+ // lists every branch, runs `git status --porcelain` and lists the worktrees, which on a large repo
257
+ // is most of the cost and was paid for every workspace every 15 seconds. `top` may be passed when
258
+ // the caller already resolved the repo root, saving one more shell-out.
259
+ function headInfo(root, top) {
260
+ const t = top === undefined ? repoRoot(root) : top;
261
+ if (!t) {
262
+ if (!isRepo(root)) return { ok: false, code: 'no-git', error: 'not a git repository' };
263
+ return { ok: false, code: 'git-error', error: 'git not available' };
264
+ }
265
+ const head = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
266
+ if (head.failed) return { ok: false, code: 'git-error', error: 'git not available' };
267
+ if (head.code !== 0) return { ok: false, code: 'git-error', error: head.err || 'could not read HEAD' };
268
+ const detached = head.out === 'HEAD';
269
+ return { ok: true, current: detached ? null : head.out, detached };
270
+ }
271
+
254
272
  // { ok, current, branches[], detached, dirty, repoRoot, subdir, linked, worktrees[] }. A non-repo
255
273
  // or a failed git returns ok:false with a reason — NEVER a fabricated branch (a wrong branch chip
256
274
  // is worse than none; anti-drift). `subdir` is the workspace's path INSIDE the repo ('' at the
@@ -284,5 +302,5 @@ function branchInfo(root) {
284
302
  };
285
303
  }
286
304
 
287
- module.exports = { isRepo, repoRoot, validBranchName, branchInfo, checkout, worktrees, worktreePath,
305
+ module.exports = { isRepo, repoRoot, validBranchName, branchInfo, headInfo, checkout, worktrees, worktreePath,
288
306
  addWorktree, worktreeSafety, removeWorktree, originUrl, isGithubRemote };
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+ /* needs-overdue.js — the PM's own work items that belong in "Needs you" because their date has come: overdue first, then due today.
3
+ *
4
+ * Pure. Input: the folded items of lib/pm-items.js and today as YYYY-MM-DD. Output: needs rows in the shape the Briefing's row builder
5
+ * (public/pm/c3-brief.js needRow) takes, so the route can send them beside the engine's cards without touching the engine.
6
+ *
7
+ * Left out: done items, parked items, items waiting on someone else, and items the PM already moved to Later (that is the answer to a row).
8
+ * Order: most days overdue first, then due today; ties by title. Each row says "overdue" or "due today" in plain words.
9
+ */
10
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
11
+ const dayNo = (s) => Math.round(Date.parse(s + 'T00:00:00Z') / 864e5);
12
+
13
+ function daysOverdue(due, today) { return dayNo(today) - dayNo(due); }
14
+
15
+ function wording(n) {
16
+ if (n > 0) return { time: 'overdue', ago: n === 1 ? '1 day overdue' : n + ' days overdue' };
17
+ return { time: 'due today', ago: 'due today' };
18
+ }
19
+
20
+ function overdueNeeds(items, today, opts = {}) {
21
+ if (!Array.isArray(items) || !DATE_RE.test(String(today || ''))) return [];
22
+ const limit = opts.limit > 0 ? opts.limit : 5;
23
+ const rows = [];
24
+ for (const it of items) {
25
+ if (!it || it.done || it.parked || it.waiting_on) continue;
26
+ if (it.list_override === 'later') continue;
27
+ if (!it.due || !DATE_RE.test(String(it.due))) continue;
28
+ const n = daysOverdue(it.due, today);
29
+ if (n < 0) continue;
30
+ const w = wording(n);
31
+ rows.push({
32
+ key: 'od-' + it.id, itemId: it.id, headline: it.title || '(untitled)', kind: n > 0 ? 'Overdue' : 'Due today', icon: 'clock',
33
+ color: n > 0 ? 'var(--destructive)' : 'var(--warning)', time: w.time, dueText: w.ago, src: 'my work', p: it.priority || null,
34
+ overdue: n > 0, days_overdue: Math.max(0, n), due: it.due,
35
+ });
36
+ }
37
+ rows.sort((a, b) => b.days_overdue - a.days_overdue || String(a.headline).localeCompare(String(b.headline)));
38
+ return rows.slice(0, limit);
39
+ }
40
+
41
+ module.exports = { overdueNeeds, daysOverdue };
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
  /* routes-briefing.js — GET /api/briefing?lens=Plan|Unblock|Close|Reflect&zoom=day|week|month[&now=ISO] (console3 wave C1).
3
3
  *
4
- * { ok, lens, zoom, cards, ticker, banner }
4
+ * { ok, lens, zoom, cards, ticker, banner, own_needs }
5
+ * own_needs: the PM's own items overdue or due today, as needs rows (lib/needs-overdue.js), overdue first. Additive; the page shows them above the cards.
5
6
  *
6
7
  * `cards` are the engine's selected cards (scripts/signals present() over readRepoFacts of the repo root), with the card
7
8
  * anatomy untouched. `ticker` is one line per top card, built from the card's own headline and deadline. `banner` is the
@@ -94,6 +95,14 @@ module.exports = {
94
95
  catch (e) { ctx.json(res, { ok: false, error: 'the signal engine failed: ' + (e && e.message ? e.message : String(e)) }, 500); return true; }
95
96
  const eng = loadEngine();
96
97
  const out = briefing(q, { root: useRoot, load: () => ({ present: eng.present, readRepoFacts: () => facts }) });
98
+ // additive: the PM's own overdue and due-today items, as needs rows beside the engine's cards (lib/needs-overdue.js); a read failure leaves the list empty
99
+ if (out.code === 200) {
100
+ try {
101
+ const pmItems = require('./pm-items.js');
102
+ const file = path.join(ctx.runtimeDir, 'pm-items', 'items.jsonl');
103
+ out.body.own_needs = require('./needs-overdue.js').overdueNeeds(pmItems.fold(pmItems.readLines(file)), iso);
104
+ } catch { out.body.own_needs = []; }
105
+ }
97
106
  ctx.json(res, out.body, out.code);
98
107
  return true;
99
108
  },
@@ -7,6 +7,10 @@
7
7
  *
8
8
  * Next step, Hand to agent, a skill draft and an alignment are NOT automatic: the PM pressed those, and often talks on.
9
9
  * Any other origin is automatic.
10
+ *
11
+ * A live session the PM started is never ended here. Once ANY session has ended, it expires after the policy's
12
+ * keepExitedDays (default 7): its conversation was recorded when it exited, and a list that only grows costs memory,
13
+ * disk, a bigger push every 2 seconds, and blocks forgetting a workspace (workspace-session-lifecycle).
10
14
  */
11
15
  'use strict';
12
16
 
@@ -24,8 +28,12 @@ function isAutomatic(origin) {
24
28
  return !(MANUAL_EXACT.includes(o) || MANUAL_PREFIX.some((p) => o.startsWith(p)));
25
29
  }
26
30
 
27
- const DEFAULT_POLICY = Object.freeze({ finish: true, idleMinutes: 60 });
31
+ const DEFAULT_POLICY = Object.freeze({ finish: true, idleMinutes: 60, keepExitedDays: 7 });
28
32
  const FINISH_GRACE_MS = 60 * 1000; // a finished job stays this long, so a PM watching it sees it end
33
+ // A job that FAILED stays long enough for the PM to see it on the next look at the workspace, then goes. Kept for ever,
34
+ // a schedule firing at a moved repo added one dead row per fire, each with its scrollback in memory and on disk.
35
+ const FAILED_KEEP_MS = 24 * 3600 * 1000;
36
+ const KEEP_DAYS_MAX = 365;
29
37
  const IDLE_MIN = 5;
30
38
  const IDLE_MAX = 7 * 24 * 60;
31
39
 
@@ -38,7 +46,14 @@ function normalizePolicy(raw) {
38
46
  else if (typeof r.idleMinutes === 'number' && Number.isFinite(r.idleMinutes)) {
39
47
  idle = Math.min(IDLE_MAX, Math.max(IDLE_MIN, Math.round(r.idleMinutes)));
40
48
  }
41
- return { finish, idleMinutes: idle };
49
+ // keepExitedDays: how long ANY ended session (the PM's too) stays in the list. Its conversation is recorded when it
50
+ // exits, so removing the row loses no work. 0 or null = keep for ever.
51
+ let keep = DEFAULT_POLICY.keepExitedDays;
52
+ if (r.keepExitedDays === null || r.keepExitedDays === 0) keep = null;
53
+ else if (typeof r.keepExitedDays === 'number' && Number.isFinite(r.keepExitedDays)) {
54
+ keep = Math.min(KEEP_DAYS_MAX, Math.max(1, Math.round(r.keepExitedDays)));
55
+ }
56
+ return { finish, idleMinutes: idle, keepExitedDays: keep };
42
57
  }
43
58
 
44
59
  /** Did the session end cleanly? The note the server writes on exit reads "exited (code 0)". Anything else is kept. */
@@ -49,23 +64,33 @@ function endedCleanly(s) {
49
64
  /**
50
65
  * What the sweep should do with one session row: 'remove' (it has ended and may go), 'end' (it is idle: stop it, and
51
66
  * it is removed on a later sweep), or null (leave it).
52
- * Row fields read: origin, state, shown, note, lastOutputAt, autoDelete ('never' opts one job out), autoEnded.
67
+ * Row fields read: origin, state, shown, note, lastOutputAt, exitedAt, autoDelete ('never' opts one out), autoEnded.
68
+ * `shown` is only read for a live automatic session past its idle limit; the caller may pass a function for it so the
69
+ * transcript lookup behind it runs only then (see needsShown).
53
70
  */
54
71
  function decide(s, policy, now) {
55
- if (!s || !isAutomatic(s.origin) || s.autoDelete === 'never') return null;
72
+ if (!s || s.autoDelete === 'never') return null;
73
+ const exitedAt = Date.parse(s.exitedAt || '') || 0;
74
+ // Every ended session, whoever started it, expires after keepExitedDays. A live session the PM started is never ended.
75
+ if (s.state === 'exited' && policy.keepExitedDays && exitedAt && now - exitedAt >= policy.keepExitedDays * 86400000) return 'remove';
76
+ if (!isAutomatic(s.origin)) return null;
56
77
  const last = Number(s.lastOutputAt) || 0;
57
- if (!last) return null; // no clock for it (restored from disk): never guess an age
58
- const age = now - last;
59
78
  if (s.state === 'exited') {
79
+ // The exit time is the better clock for an ended job; the output clock is the fallback for a row from before it.
80
+ const age = now - (exitedAt || last);
81
+ if (!exitedAt && !last) return null; // no clock for it: never guess an age
60
82
  if (!policy.finish && !s.autoEnded) return null;
61
- if (!s.autoEnded && !endedCleanly(s)) return null; // a failed job needs the PM's eye
83
+ if (!s.autoEnded && !endedCleanly(s)) return age >= FAILED_KEEP_MS ? 'remove' : null; // a failed job: the PM's eye first
62
84
  return age >= FINISH_GRACE_MS ? 'remove' : null;
63
85
  }
86
+ if (!last) return null; // no clock for it (restored from disk): never guess an age
64
87
  if (!policy.idleMinutes) return null;
88
+ if (now - last < policy.idleMinutes * 60 * 1000) return null;
65
89
  // A call waiting for permission is a real ask. 'needs' (the agent's turn is over) is NOT: a finished agent always reads
66
90
  // 'needs you', so exempting it would keep every finished job for ever. The idle clock still gives a late question time.
67
- if (s.shown === 'blocked') return null;
68
- return age >= policy.idleMinutes * 60 * 1000 ? 'end' : null;
91
+ const shown = typeof s.shown === 'function' ? s.shown() : s.shown;
92
+ if (shown === 'blocked') return null;
93
+ return 'end';
69
94
  }
70
95
 
71
- module.exports = { isAutomatic, normalizePolicy, decide, endedCleanly, DEFAULT_POLICY, FINISH_GRACE_MS, MANUAL_EXACT, MANUAL_PREFIX };
96
+ module.exports = { isAutomatic, normalizePolicy, decide, endedCleanly, DEFAULT_POLICY, FINISH_GRACE_MS, FAILED_KEEP_MS, MANUAL_EXACT, MANUAL_PREFIX };
@@ -66,13 +66,21 @@ function slugFor(cwd) {
66
66
  * Default descendant lister: ONE `ps` snapshot → a ppid→children map → the direct children of
67
67
  * `pid`. Injected in tests so the walk is exercised without real processes. Returns [] on any
68
68
  * failure — a resolver miss must degrade to the refusal, never throw.
69
+ *
70
+ * The snapshot is shared for PS_SNAPSHOT_MS. The walk asks for the children of every pid it visits,
71
+ * and the server resolves every shell pane on each 2s heartbeat, so an uncached lister ran one
72
+ * synchronous `ps` per visited pid per pane per push: 10 `ps` runs per push with six shell panes,
73
+ * each one blocking the thread that carries keystrokes. One snapshot answers all of them.
69
74
  */
70
- function defaultChildren(pid) {
75
+ const PS_SNAPSHOT_MS = 1000;
76
+ let psSnap = null; // { at, kids: Map<ppid, pid[]> }
77
+ function psChildrenMap(now = Date.now()) {
78
+ if (psSnap && now - psSnap.at < PS_SNAPSHOT_MS) return psSnap.kids;
71
79
  let out;
72
80
  try {
73
81
  out = cp.execFileSync('ps', ['-Ao', 'pid=,ppid='], { encoding: 'utf8', timeout: 2000 });
74
82
  } catch {
75
- return [];
83
+ return null; // not cached: tmux or ps can be transiently busy, and the next call tries again
76
84
  }
77
85
  const kids = new Map();
78
86
  for (const line of out.split('\n')) {
@@ -83,7 +91,12 @@ function defaultChildren(pid) {
83
91
  if (!kids.has(pp)) kids.set(pp, []);
84
92
  kids.get(pp).push(p);
85
93
  }
86
- return kids.get(Number(pid)) || [];
94
+ psSnap = { at: now, kids };
95
+ return kids;
96
+ }
97
+ function defaultChildren(pid) {
98
+ const kids = psChildrenMap();
99
+ return (kids && kids.get(Number(pid))) || [];
87
100
  }
88
101
 
89
102
  /** Breadth-first descendants of `pid`, bounded to `maxDepth` levels. `listChildren(pid)->pids`. */
@@ -303,4 +316,4 @@ function readHarness(file, clean = (x) => x) {
303
316
  }
304
317
 
305
318
  module.exports = { findTranscript, readHarness, prettyTool, slugFor, resolvePid, descendants,
306
- MAX_BYTES, MAX_WALK_DEPTH };
319
+ MAX_BYTES, MAX_WALK_DEPTH, PS_SNAPSHOT_MS, defaultChildren };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.125.0",
3
+ "version": "0.127.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.125.0",
9
+ "version": "0.127.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.125.0",
3
+ "version": "0.127.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  /* pm/briefing-deck.js — the Briefing view (console3 wave C1), built on the console3 mockup's own markup and CSS.
2
2
  * The mockup (prototype/pmos-console-handoff/mockups/console3.html) is the UI: the stance bar with the day-part switch (.stance,
3
- * .seg.dp), the running line (.tick, .tkt), the card deck (.deck, .fc, .cf, .hero, .dk, .dkd), the flip to the case file (.fb,
3
+ * the lens follows the clock, no switch), the running line (.tick, .tkt), the card deck (.deck, .fc, .cf, .hero, .dk, .dkd), the flip to the case file (.fb,
4
4
  * .case, .chgl, .opts, .slt, .cft), the tilt and the flip animation. The styles are pm/console3.css (see pm/console3-lift.md).
5
5
  * This file keeps the data call (GET /api/briefing, the signal engine's cards) and the model (briefing-model.js) and draws
6
6
  * what the engine sent: at most five cards, none invented. A field the engine does not supply is left out.
@@ -11,6 +11,7 @@
11
11
  * plot, so their facts are listed as text and the kind is named).
12
12
  * Not registered with the registry (Today has no view registry): dest-today.js imports mountBriefing/disposeBriefing. */
13
13
  import { el } from './dom.js';
14
+ import { createIdleGate, watchIdle, applyDayPart } from './c3-daypart.js';
14
15
  import { LENSES, TAG_STYLE, PART_STYLE, cardView, dayPartFor, nextCards, swipeDir, sizeFor, caseModelOf, rawOption } from './briefing-model.js';
15
16
  import { BX, caseFile, toast } from './c3-brief.js';
16
17
  import { ensureC3Css, c3ic, c3ring, c3Phone } from './pm-work-view.js';
@@ -154,7 +155,7 @@ const pct = (x) => Math.round(Math.max(0, Math.min(1, x)) * 100);
154
155
  function eyebrow(v) {
155
156
  const T = tagStyle(v);
156
157
  if (v.eyebrow) return [el('span', { class: 'tg', style: '--tc:var(--muted-foreground)' }, c3ic(v.eyebrow.icon, 'sm'), v.eyebrow.label), v.pov ? el('span', { class: 'tag pov' }, v.pov) : null];
157
- const tg = el('span', { class: 'tg' + (T.filled ? ' f' : ''), title: T.q }, c3ic(T.icon, 'sm'), v.tag);
158
+ const tg = el('span', { class: 'tg' + (T.filled ? ' f' : ''), title: T.q }, c3ic(v.icon || T.icon, 'sm'), v.tag);
158
159
  return v.pov ? [tg, el('span', { class: 'tag pov' }, v.pov)] : tg;
159
160
  }
160
161
 
@@ -226,7 +227,7 @@ function back(S, v) {
226
227
  function card(S, v, i, n) {
227
228
  const T = tagStyle(v); const bk = back(S, v);
228
229
  const c = el('article', {
229
- class: 'fc' + (i === 0 ? ' on' : ''), tabindex: '0', role: 'button', 'aria-expanded': 'false', 'aria-description': 'card ' + (i + 1) + ' of ' + n,
230
+ class: 'fc flipcard' + (i === 0 ? ' on' : ''), tabindex: '0', role: 'button', 'aria-expanded': 'false', 'aria-description': 'card ' + (i + 1) + ' of ' + n,
230
231
  'data-a': 'cflip', 'data-bf': 'flip', 'data-bf-card': v.key || '', 'data-tag': T.key, 'data-tag-label': v.tag, style: '--tc:' + (v.eyebrow ? 'var(--muted-foreground)' : T.color),
231
232
  }, front(v), el('span', { class: 'flipg', 'aria-hidden': 'true' }, c3ic('undo', 'sm')), bk);
232
233
  bk.bind(c);
@@ -243,11 +244,10 @@ function flipTo(S, c, force) {
243
244
  if (!c.classList.contains('on') && !c.classList.contains('back')) { deck.scrollTo({ left: c.offsetLeft - deck.children[0].offsetLeft, behavior: reduced() ? 'auto' : 'smooth' }); return; }
244
245
  const toBack = force !== undefined ? force : !c.classList.contains('back');
245
246
  deck.querySelectorAll('.fc.back').forEach((o) => { if (o !== c) { o.classList.remove('back'); o.setAttribute('aria-expanded', 'false'); } });
246
- const sw = () => { c.classList.toggle('back', toBack); c.setAttribute('aria-expanded', String(toBack)); if (toBack && c.scrollIntoView) c.scrollIntoView({ block: 'nearest', behavior: reduced() ? 'auto' : 'smooth' }); };
247
+ /* R2: the card has one fixed size and both faces are in it, so the flip is a CSS transition of rotateY (console3.css .flipcard, about 350 ms).
248
+ * Nothing is measured and nothing scrolls: no sibling and nothing below the deck moves. prefers-reduced-motion turns the transition off. */
249
+ c.classList.toggle('back', toBack); c.setAttribute('aria-expanded', String(toBack));
247
250
  S.flip = toBack ? c.dataset.bfCard || '1' : null;
248
- if (reduced()) { sw(); return; }
249
- c.classList.add('fl-out');
250
- setTimeout(() => { c.classList.remove('fl-out'); sw(); c.classList.add('fl-in'); setTimeout(() => c.classList.remove('fl-in'), 320); }, 170);
251
251
  }
252
252
 
253
253
  function deckSync(S, deck) {
@@ -323,20 +323,13 @@ async function load(S, lens) {
323
323
  else { const old = S.root.querySelector('.stance'); if (old) old.replaceWith(stance(S)); } // another lens arrived: update the switch, keep the open card
324
324
  }
325
325
 
326
- const DAYPART = [['m', 'Morning', '08:12'], ['n', 'Midday', '12:30'], ['a', 'Afternoon', '16:05'], ['e', 'Evening', '18:30']]; // LENSES order: Plan, Unblock, Close, Reflect
327
- /* the mockup's stanceBar (line 2332): the lens icon, its name and line, and the day-part switch as .seg.dp icon buttons */
326
+ /* the stance bar (the mockup's stanceBar, line 2332): the lens icon and its name and line. The lens follows the clock; there is no switch (console3 R4). */
328
327
  function stance(S) {
329
328
  const P = PART_STYLE[S.lens] || PART_STYLE.Plan;
330
329
  return el('div', { class: 'stance', 'data-bf': 'lens-bar' },
331
330
  el('span', { class: 'ki' }, c3ic(P.icon)),
332
- el('div', null, el('b', { title: S.lens === S.clock ? 'Follows the clock: this is the part of the day it is now.' : null }, S.lens), el('span', { class: 'dim' }, P.sub)),
333
- listenButton(() => { const x = S.data[S.lens]; return x && x.ok ? x.cards[S.i] || x.cards[0] : null; }), // the mockup's speaker (data-a=listen) reads the card on top
334
- el('div', { class: 'seg dp', role: 'group', 'aria-label': 'Part of the day', title: 'Follows the clock. Tap to preview another part of the day.' },
335
- ...LENSES.map((l, qi) => {
336
- const Q = PART_STYLE[l]; const N = DAYPART[qi]; // the mockup's PARTS (line 2206): the part is named by its time of day, the lens by what it does
337
- return el('button', { type: 'button', 'aria-pressed': String(l === S.lens), 'data-bf-lens': l, 'data-a': 'tod', 'data-k': N[0], title: N[1] + ' · ' + N[2], 'aria-label': N[1],
338
- onClick: () => { S.lens = l; S.i = 0; S.flip = null; load(S, l); draw(S); if (S.ctx && S.ctx.onLens) S.ctx.onLens(l); } }, c3ic(Q.icon, 'sm'));
339
- })));
331
+ el('div', null, el('b', { title: 'Follows the clock.' }, S.lens), el('span', { class: 'dim' }, P.sub)),
332
+ listenButton(() => { const x = S.data[S.lens]; return x && x.ok ? x.cards[S.i] || x.cards[0] : null; })); // the mockup's speaker (data-a=listen) reads the card on top
340
333
  }
341
334
 
342
335
  /* the mockup's running line (.tick > .tkt, lines 539 to 545): the engine's ticker strings, twice so the marquee loops */
@@ -382,6 +375,11 @@ export function mountBriefing(host, ctx) {
382
375
  S.stopPhone = c3Phone(root);
383
376
  roots.set(host, S);
384
377
  draw(S);
378
+ if (!host.closest('[data-daypart]')) applyDayPart(root); // a page without the console3 shell still gets the day part tokens
379
+ /* The clock moves the lens, but never under the reader: a new part waits for 60 s without pointer or key activity and for every card to be turned back. */
380
+ const gate = createIdleGate({ current: S.lens, part: (ms) => dayPartFor(new Date(ms)), busy: () => !!S.flip || !!S.root.querySelector('.fc.back'),
381
+ apply: (l) => { if (S.dead) return; S.clock = l; S.lens = l; S.i = 0; S.flip = null; load(S, l); draw(S); if (S.ctx.onLens) S.ctx.onLens(l); } });
382
+ S.idle = watchIdle(gate);
385
383
  if (S.ctx.onLens) S.ctx.onLens(S.lens); // the page may follow the lens from the start (Reflect: "Carries over", "What I could stop")
386
384
  // the shown lens first, then the others for their preview line
387
385
  load(S, S.lens).then(() => Promise.all(LENSES.filter((l) => l !== S.lens).map((l) => load(S, l))));
@@ -410,6 +408,6 @@ export function flipBack(host) {
410
408
 
411
409
  export function disposeBriefing(host) {
412
410
  const S = roots.get(host);
413
- if (S) { S.dead = true; if (S.stopPhone) S.stopPhone(); }
411
+ if (S) { S.dead = true; if (S.stopPhone) S.stopPhone(); if (S.idle) S.idle.dispose(); }
414
412
  roots.delete(host);
415
413
  }
@@ -22,6 +22,39 @@ export const PART_STYLE = {
22
22
  Close: { icon: 'check', sub: 'Send and finish before you leave' },
23
23
  Reflect: { icon: 'moon', sub: 'What moved, what carries over' },
24
24
  };
25
+ /* R6: the icon of a card is chosen by code. Keyword rules run over the card's kind, then over its signal type; the first rule that
26
+ * matches wins, and a kind that matches no rule gets the neutral 'info' icon. Every name is a symbol of the vendored sprite
27
+ * (pm-work-view.js SPRITE, `c3i-*`). The five starting tags map to the icons TAG_STYLE gives them. No model call, no network. */
28
+ export const NEUTRAL_ICON = 'info';
29
+ export const ICON_RULES = [
30
+ [/\bsmall win\b|\bprogress\b|\bimproved?\b/, 'up'],
31
+ [/\bbig win\b|\bwin\b|\bmilestone\b|\blaunch|\bship(ped)?\b|\brelease/, 'flag'],
32
+ [/\bcritical\b|\brisks?\b|\bblock|\bincident|\boutage|\boverdue\b|\bescalat|\bregress|\bfail/, 'alert'],
33
+ [/\bsecurity\b|\bcompliance\b|\bprivacy\b|\bpolicy\b/, 'shield'],
34
+ [/\bdeadline|\bdue\b|\bmoved\b|\bslip|\bdelay|\bschedule|\bwaiting\b|\bwaits?\b/, 'clock'],
35
+ [/\bdecision|\bapprov|\btrade-?off|\bchoose|\bchoice/, 'scale'],
36
+ [/\bopportunit|\bidea\b|\binsight|\bsuggest/, 'spark'],
37
+ [/\bjira\b|\bticket|\bissue|\bnew from\b|\binbound\b/, 'inbox'],
38
+ [/\bmeeting|\bcalendar|\bevent\b|\bagenda/, 'cal'],
39
+ [/\bmail|\bemail|\breply\b|\bmessage/, 'mail'],
40
+ [/\bperson|\bpeople|\bstakeholder|\bowner\b|\bteam\b|\bcustomer/, 'user'],
41
+ [/\bokr\b|\bgoal|\bobjective|\bkey result|\btarget\b/, 'target'],
42
+ [/\bmetric|\bkpi\b|\btrend|\breport|\bnumber|\bburn/, 'report'],
43
+ [/\bpull request|\bpr\b|\bmerge|\bbranch|\bcommit|\bcode\b|\brepo/, 'branch'],
44
+ [/\bdoc\b|\bdocs\b|\bspec\b|\bprd\b|\bnote/, 'doc'],
45
+ [/\bjust in\b|\bnew\b|\bnews\b|\bupdate|\bsignal|\bchanged?\b/, 'bolt'],
46
+ ];
47
+ /** The icon name for a card of this kind and signal type. Pure. Unknown or empty gives the neutral icon. */
48
+ export function iconFor(kind, signalType) {
49
+ for (const raw of [kind, signalType]) {
50
+ const t = String(raw === null || raw === undefined ? '' : raw).toLowerCase().replace(/[_-]+/g, ' ');
51
+ if (!t.trim()) continue;
52
+ const hit = ICON_RULES.find((r) => r[0].test(t));
53
+ if (hit) return hit[1];
54
+ }
55
+ return NEUTRAL_ICON;
56
+ }
57
+
25
58
  export const HEROES = ['line', 'big-number', 'ring', 'bars', 'two-bars', 'rows', 'mini-graph'];
26
59
 
27
60
  /** 'L' at 500 and up, 'M' from 400 to 499, 'S' under 400. */
@@ -52,6 +85,8 @@ export function cardView(card) {
52
85
  const w = c.why || null;
53
86
  return {
54
87
  key: c.key || null,
88
+ kind: String(c.kind || c.tag || tag),
89
+ icon: iconFor(c.kind || c.tag || tag, c.signal || c.signalType || c.type),
55
90
  tag, mark: TAG_MARK[tag], pinned: !!c.pinned,
56
91
  headline: String(c.headline || ''),
57
92
  hero: { kind, points: Array.isArray(h.points) ? h.points.map(num).filter((x) => x !== null) : null },
@@ -136,7 +171,7 @@ export function needCaseOf(n) {
136
171
  if (n.view) return caseModelOf(n.view, { sim: !!n.sim }); // a real row is one engine card: its case is the card's own
137
172
  return {
138
173
  id: 'need:' + n.key, situation: n.headline,
139
- evidence: [{ icon: 'clock', text: 'Due ' + (n.time || 'soon'), source: n.src || '' }],
174
+ evidence: [{ icon: 'clock', text: n.dueText ? n.dueText : 'Due ' + (n.time || 'soon'), source: n.src || '' }],
140
175
  options: [], outward: true, outboxReason: NO_ROUTE,
141
176
  };
142
177
  }
@@ -15,6 +15,9 @@
15
15
  * options `.opts` buttons `.opt`, the recommended one first with `.rec`, its confidence ("Recommended · 82%") and a
16
16
  * `.conf` bar; each carries its effects (`.fx`: icon + text, tone). One is pressed (aria-pressed,
17
17
  * role=radio): the recommended one until the person picks another.
18
+ * confirm `.c3hold` R3: where the main pointer is fine, the slide below is replaced by a compact button: press and hold 600 ms
19
+ * (a ring fills) for an act that cannot be undone, one click for one that can, Enter then Confirm from
20
+ * the keyboard (c3-swipe.js mountHold, holdMode). `opts.fine` forces the choice; the default asks matchMedia.
18
21
  * slide `.slt` the slide-to-confirm (slide-confirm.js mountSlide). "Slide to choose this instead" and the grey
19
22
  * `.alt` look when the pick is not the recommended one. It is bound to the picked option: its
20
23
  * confirm hands back `hash` = `<case id>|<option id>`, the content the person saw.
@@ -26,6 +29,7 @@
26
29
  import { el } from './dom.js';
27
30
  import { c3ic } from './pm-work-view.js';
28
31
  import { mountSlide, disposeSlide } from './slide-confirm.js';
32
+ import { mountHold, holdMode, finePointer } from './c3-swipe.js';
29
33
 
30
34
  /** The claim labels of the mockup (C3.LAB): [icon, tag, tone]. */
31
35
  export const LABELS = {
@@ -62,7 +66,7 @@ function effectOf(fx) {
62
66
  function optionOf(o, i) {
63
67
  const x = o || {};
64
68
  return { id: str(x.id !== undefined ? x.id : 'o' + i), label: str(x.l !== undefined ? x.l : x.label), rec: !!(x.rec || x.recommended),
65
- effects: (x.fx || x.effects || []).map(effectOf), outward: !!x.outward, confidence: x.conf !== undefined || x.confidence !== undefined ? pct(x.conf !== undefined ? x.conf : x.confidence) : null };
69
+ effects: (x.fx || x.effects || []).map(effectOf), outward: !!x.outward, irreversible: !!x.irreversible, confidence: x.conf !== undefined || x.confidence !== undefined ? pct(x.conf !== undefined ? x.conf : x.confidence) : null };
66
70
  }
67
71
 
68
72
  /** The canonical case. Exactly one option is the recommended one: the first marked, else the first (the mockup's recId). */
@@ -127,12 +131,20 @@ export function renderCase(model, opts = {}) {
127
131
  o.rec ? el('div', { class: 'conf' }, el('i', { style: 'width:' + c.confidence + '%' })) : null);
128
132
  }));
129
133
  };
134
+ let disposeHold = null;
130
135
  const drawSlide = () => {
131
136
  disposeSlide(slideHost);
137
+ if (disposeHold) { disposeHold(); disposeHold = null; }
132
138
  slideHost.replaceChildren();
133
139
  const s = slideFor(c, picked, { outbox });
134
140
  if (s.none) return;
135
- if (s.enabled) {
141
+ const fine = opts.fine !== undefined ? !!opts.fine : finePointer();
142
+ if (s.enabled && fine) {
143
+ const trk = el('div', { class: 'c3confirm', 'data-case-slt': c.id || null });
144
+ slideHost.append(trk);
145
+ disposeHold = mountHold(trk, { label: s.option.label || s.label, mode: holdMode(s.option, c), hash: c.id + '|' + s.option.id, alt: s.option.id !== c.recId,
146
+ onConfirm: (hash) => { if (opts.onConfirm) opts.onConfirm({ option: s.option, hash, case: c }); } });
147
+ } else if (s.enabled) {
136
148
  mountSlide(slideHost, { label: s.label, hash: c.id + '|' + s.option.id, onConfirm: (hash) => { if (opts.onConfirm) opts.onConfirm({ option: s.option, hash, case: c }); } });
137
149
  const trk = slideHost.querySelector('.slt');
138
150
  if (trk && s.option.id !== c.recId) trk.classList.add('alt');
@@ -168,6 +180,6 @@ export function renderCase(model, opts = {}) {
168
180
  goBtn));
169
181
  }
170
182
  /* a handle for the caller: read or set the pick, and tear the slide down when the case leaves the page */
171
- root.c3case = { get picked() { return picked; }, pick, model: c, dispose() { if (slideHost) disposeSlide(slideHost); } };
183
+ root.c3case = { get picked() { return picked; }, pick, model: c, dispose() { if (slideHost) disposeSlide(slideHost); if (disposeHold) { disposeHold(); disposeHold = null; } } };
172
184
  return root;
173
185
  }