create-pmos 0.23.0 → 0.24.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/README.md CHANGED
@@ -333,6 +333,34 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
333
333
  `okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
334
334
  exactly what you're running.
335
335
 
336
+ ## What's new in 0.24.0
337
+
338
+ **The initiative board says which work is yours, and which of it has an agent running.** Run
339
+ `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
340
+
341
+ - **A `NOW · working on` group is pinned at the top of the board.** With a few dozen initiatives
342
+ filed by objective, the list is in the right *filing* order and the wrong *attention* order — the
343
+ one you have an agent running on sits wherever its KR happens to fall. This group collects what
344
+ you are actually on, ordered so whatever is **waiting on you** is the first row on the screen.
345
+ - **Three signals, ranked and told apart.** An initiative is in the group because a session is live
346
+ on its `initiative/<id>` branch, or a working tree is checked out on that branch with nothing
347
+ running in it, or you have a tab open on it in this window. They are never merged into one vague
348
+ "active" badge — each row's tooltip says which one put it there, and the two weaker ones say
349
+ plainly that nothing is running.
350
+ - **A live session shows on its own row.** The `#` beside an initiative becomes that session's own
351
+ glyph — `◆ needs you`, `● working`, `◇ maybe blocked`, `◼ quiet` — in the same vocabulary the
352
+ session sidebar uses. Hovering names **every** session on that initiative with its state, rather
353
+ than counting them: the status bar already says how many and never which. Several sessions fold to
354
+ the one that most wants you, and `maybe blocked` outranks `working`.
355
+ - **The glyphs differ in shape, not just colour**, so the board still reads in greyscale and for a
356
+ colour-blind reader.
357
+ - **Nothing is re-homed and nothing is invented.** An initiative still appears under its own
358
+ objective and KR — the group is a shortcut, not a second filing system — and it disappears
359
+ entirely when there is nothing to say. A session PMOS cannot place on an initiative is not placed
360
+ on one.
361
+ - **Searching for a session name now finds it.** It only ever matched initiatives you had already
362
+ opened in that window, and returned nothing for the rest without saying it had not looked.
363
+
336
364
  ## What's new in 0.21.0
337
365
 
338
366
  **The workspace terminal's lines are no longer double-spaced.** Run
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Scaffold the PMOS portable kit \u2014 run the PMOS product-management workflow (initiative \u2192 lane-fit spec \u2192 contract \u2192 build \u2192 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@76ed2d3
1
+ kernel-source: pmos@ac2ee81
2
2
  assembled: 2026-07-31
3
3
  mode: vendor
@@ -67,6 +67,16 @@ bar and a 26px status bar, on the oklch token layer, in both themes:
67
67
  2. **Initiatives** — a channel list. Collapsible objective/KR groups over `#slug` rows, one search
68
68
  box that forces the groups open and reports matches elsewhere, and an add form. Unanchored
69
69
  initiatives are filed honestly and shown **rejected** under D59, never hidden.
70
+ A **`NOW · working on`** group is pinned above the objectives when — and only when — there is
71
+ something to say: every initiative with a live session on its `initiative/<id>` branch, a
72
+ registered working tree on that branch, or a tab open in this window, ordered so whatever is
73
+ **waiting on you** is the first row on the board. The three signals are ranked, never merged, and
74
+ each row's tooltip names which one put it there. On a row with a live session the leading `#`
75
+ becomes that session's own glyph — `◆ needs you`, `● working`, `◇ maybe blocked`, `◼ quiet` —
76
+ in the vocabulary `lib/session-state.js` uses everywhere else, with every session named
77
+ individually on hover. The glyphs differ in **shape**, so the signal survives greyscale.
78
+ The join is the branch convention and nothing else: a session PMOS cannot place on an initiative
79
+ is not placed on one.
70
80
  3. **Initiative detail** — the record's header and pills (type / anchor / flow / branch), the derived
71
81
  footer, **task phases** (collapsible; a phase's status is derived from its tasks, and the tasks
72
82
  are derived from the workflow ladder unless the record authors them), a **links** section for
@@ -66,8 +66,62 @@
66
66
 
67
67
  // Filled = corroborated by both sources, hollow = suspected from one. `needs` takes ◆, which has
68
68
  // sat declared-but-dead in the client's GLYPH map since the harness tower; this makes it live.
69
+ //
70
+ // THE SHAPES CARRY THE SIGNAL, AND COLOUR ONLY REINFORCES IT. Every pair in this map is
71
+ // distinguishable in greyscale, which is the property `fixture.js`'s SDOT does not have (it gives
72
+ // `working` and `needs` the same ● and separates them by hue alone). That map is the design
73
+ // prototype's seed data, parity-locked to prototype-original.html; this one is the shipped
74
+ // vocabulary. When a surface has to draw a session, it draws from here.
69
75
  const GLYPH = { working: '●', quiet: '◼', exited: '○', needs: '◆', blocked: '◇' };
70
76
 
77
+ // The colour that goes with the glyph, as a SEMANTIC TOKEN NAME — never a hex, never an oklch.
78
+ // The workspace's own token layer (pm/tokens.css, D38) resolves it, and workspace-ci's
79
+ // token-hygiene step fails on raw hex in a component rule.
80
+ //
81
+ // It lives HERE, beside WORD and GLYPH, for the reason the module's header gives: a second copy of
82
+ // one table is free to drift. The initiative board needed a colour per presented state and there
83
+ // was none anywhere in the repo — adding one inside the panel would have started exactly the
84
+ // second table this file exists to prevent.
85
+ //
86
+ // `blocked` takes --warning and not --destructive on purpose. Its own WORD names its uncertainty
87
+ // ("maybe blocked") because its evidence cannot separate a permission prompt from a hung tool from
88
+ // a corpse; a destructive colour would launder that into a confident claim of failure.
89
+ // `quiet` is muted: nothing is happening and nothing is wrong, and any louder colour would make an
90
+ // idle session read as a problem on a board of 178 rows.
91
+ const COLOR = {
92
+ working: 'var(--success)',
93
+ quiet: 'var(--muted-foreground)',
94
+ exited: 'var(--muted-foreground)',
95
+ needs: 'var(--brand)',
96
+ blocked: 'var(--warning)',
97
+ };
98
+
99
+ /**
100
+ * How loudly a presented state asks for the PM, highest first. Used where several sessions have to
101
+ * collapse into ONE mark — a board row standing for every session on an initiative.
102
+ *
103
+ * `blocked` outranks `working` deliberately. A working session is doing what it was asked to; a
104
+ * "maybe blocked" one may be sitting on a permission prompt, which is the PM's to clear. Ranking
105
+ * it below the running states would bury the only two states that can want a human.
106
+ *
107
+ * `exited` is 0: a corpse is not a claim on anyone's attention. A caller filtering live sessions
108
+ * drops it before it gets here; the entry exists so the map is total over PRESENTED.
109
+ */
110
+ const ATTENTION = { needs: 4, blocked: 3, working: 2, quiet: 1, exited: 0 };
111
+
112
+ /**
113
+ * The presented state that should stand for a whole GROUP of sessions — the loudest one.
114
+ * Returns null for an empty list, so a caller cannot draw a mark for nothing.
115
+ */
116
+ function loudest(states) {
117
+ let best = null;
118
+ for (const s of states || []) {
119
+ if (!(s in ATTENTION)) continue; // an unknown word is never promoted into a claim
120
+ if (best === null || ATTENTION[s] > ATTENTION[best]) best = s;
121
+ }
122
+ return best;
123
+ }
124
+
71
125
  // Which presented states may carry the open call's verb + age. Unchanged for `working` (shipped
72
126
  // behaviour); extended to `blocked`, where the age is the most informative part of a weak claim —
73
127
  // "a call has been open 6m and nothing is moving" is the whole signal.
@@ -134,6 +188,6 @@
134
188
  return w;
135
189
  }
136
190
 
137
- return { CADENCE, TURN, PRESENTED, WORD, GLYPH, CARRIES_CALL_AGE,
138
- presented, presentedStates, isNeedsClaim, label };
191
+ return { CADENCE, TURN, PRESENTED, WORD, GLYPH, COLOR, ATTENTION, CARRIES_CALL_AGE,
192
+ presented, presentedStates, isNeedsClaim, loudest, label };
139
193
  });
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+ // working-set — which initiatives is the PM actually working on, and which of those are live.
3
+ //
4
+ // Two questions the initiative board could not answer, asked by the PM about their own board of 178
5
+ // records: *which of these am I currently working on*, and *which one has an active session*. Both
6
+ // were computable from state the browser already held, and nothing put them together.
7
+ //
8
+ // ── THE JOIN, AND WHY IT IS ONLY EVER TWO HOPS ───────────────────────────────────────────────
9
+ // A session carries NO initiative id. It never has. The only thing tying a PTY to an initiative is
10
+ // the branch convention — a working tree on `initiative/<id>` — and that is exactly the join
11
+ // lib/four-panel.js `sessionsFor()` already makes on the server, per initiative, inside
12
+ // /api/initiative:
13
+ //
14
+ // session ──s.ws──▶ workspace ──w.branch──▶ "initiative/<id>" ──▶ the initiative
15
+ //
16
+ // This module is that same join, made once over ALL sessions instead of once per initiative, so the
17
+ // board can colour 178 rows without 178 requests. It is deliberately NOT a second opinion: it takes
18
+ // the branch strings the server derived (`git rev-parse --abbrev-ref`, published on /api/workspaces)
19
+ // and the session rows the server published, and it runs no git and guesses at nothing.
20
+ //
21
+ // ── WHAT IT REFUSES TO DO ────────────────────────────────────────────────────────────────────
22
+ // Every input that does not resolve contributes NOTHING. A session whose workspace is unknown, a
23
+ // workspace with no branch, a branch that is not `initiative/…`, a branch that is the literal
24
+ // `initiative/` with no id — each is dropped in silence rather than bucketed somewhere plausible.
25
+ // A marker on the wrong initiative is worse than no marker: the PM would stop trusting the column,
26
+ // and an untrusted signal costs more than an absent one.
27
+ //
28
+ // `exited` is not live. A session that has ended is a corpse, not a claim on anyone's attention, and
29
+ // it is filtered before the loudest-state fold rather than ranked at the bottom of it.
30
+ //
31
+ // ── THREE SIGNALS, KEPT APART ────────────────────────────────────────────────────────────────
32
+ // "Working on" is not one fact. It is three, with different strengths and different lifetimes:
33
+ //
34
+ // session a live PTY is on initiative/<id> — strongest; ends when the process does
35
+ // tree a registered working tree is on that branch — nothing is running in it
36
+ // open this window has a tab open on it — intent, not machine state; persisted
37
+ //
38
+ // They are ranked, never merged. `reason` survives all the way to the row's tooltip so the surface
39
+ // can say which one it is: a single undifferentiated "active" badge would assert something none of
40
+ // the three supports on its own.
41
+ //
42
+ // Pure functions over plain rows, like lib/tree-share.js and for the same reason — the rules are the
43
+ // part worth walking exhaustively in node, without a browser and without a server.
44
+ //
45
+ // ── ONE module, TWO consumers ────────────────────────────────────────────────────────────────
46
+ // A UMD, so the SERVER can hold the same rules the BROWSER draws from. Today the drawing consumer is
47
+ // pm/panel-initiatives.js; test.js is the other, and it walks the whole input domain.
48
+ (function (root, factory) {
49
+ const api = factory(
50
+ typeof require === 'function' && typeof module !== 'undefined' && module.exports
51
+ ? require('./session-state')
52
+ : (root && root.PMOSSessionState),
53
+ );
54
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
55
+ else root.PMOSWorkingSet = api;
56
+ })(typeof self !== 'undefined' ? self : this, function (SS) {
57
+
58
+ const PREFIX = 'initiative/';
59
+
60
+ /** The three reasons an initiative is in the working set, loudest first. */
61
+ const REASONS = ['session', 'tree', 'open'];
62
+ const REASON_RANK = { session: 2, tree: 1, open: 0 };
63
+
64
+ /**
65
+ * The initiative id a branch name names, or null.
66
+ *
67
+ * Strict on purpose. `initiative/` with nothing after it is not an initiative, and neither is
68
+ * `feature/initiative/x` — the convention is a PREFIX, and matching it loosely is how a marker
69
+ * lands on a row that has nothing to do with the session.
70
+ */
71
+ function initiativeOfBranch(branch) {
72
+ const b = typeof branch === 'string' ? branch : '';
73
+ if (!b.startsWith(PREFIX)) return null;
74
+ const id = b.slice(PREFIX.length);
75
+ return id ? id : null;
76
+ }
77
+
78
+ /** Is this session row one the board should draw? A corpse is not. */
79
+ function isLive(s) {
80
+ return !!s && s.shown !== 'exited';
81
+ }
82
+
83
+ /**
84
+ * Every LIVE session, grouped by the initiative its working tree is on.
85
+ *
86
+ * sessions the rows /api/sessions publishes (id, ws, name, shown, label, …)
87
+ * branchOf ws id → branch string, or null. The caller supplies it because the browser holds
88
+ * it as a Map built by foldWorkspaces and the server holds it as a workspace list;
89
+ * taking a function rather than either shape keeps this module ignorant of both.
90
+ *
91
+ * Returns `{ <id>: [session, …] }` — insertion-ordered, never containing an empty array, and
92
+ * never containing an initiative the join could not actually reach.
93
+ */
94
+ function sessionsByInitiative(sessions, branchOf) {
95
+ const out = Object.create(null);
96
+ if (typeof branchOf !== 'function') return out;
97
+ for (const s of Array.isArray(sessions) ? sessions : []) {
98
+ if (!isLive(s) || !s.ws) continue;
99
+ let id = null;
100
+ // A branch lookup that THROWS must not take the board down with it. The map is the data
101
+ // layer's and this module does not own its failure modes.
102
+ try { id = initiativeOfBranch(branchOf(s.ws)); } catch { id = null; }
103
+ if (!id) continue;
104
+ (out[id] || (out[id] = [])).push(s);
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /**
110
+ * The one presented state that stands for a whole initiative's sessions — the loudest, via
111
+ * session-state's own ATTENTION order. Null when there is nothing live, so a caller cannot draw
112
+ * a mark for nothing.
113
+ */
114
+ function stateOf(sessions) {
115
+ if (!SS || typeof SS.loudest !== 'function') return null;
116
+ return SS.loudest((sessions || []).map((s) => s && s.shown));
117
+ }
118
+
119
+ /**
120
+ * Every initiative a registered working tree is checked out on.
121
+ *
122
+ * `branches` is a flat list of branch strings — the caller enumerates its own registry. A tree on
123
+ * initiative/<id> means the PM has a checkout dedicated to that initiative, whether or not
124
+ * anything is running in it; that is a weaker claim than a live session and a real one.
125
+ */
126
+ function initiativesWithTree(branches) {
127
+ const out = new Set();
128
+ for (const b of Array.isArray(branches) ? branches : []) {
129
+ const id = initiativeOfBranch(b);
130
+ if (id) out.add(id);
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Every initiative this window has a tab open on.
137
+ *
138
+ * The weakest of the three signals and the only one that reflects INTENT rather than machine
139
+ * state. It is also durable: pm/state.js persists `tabs`, so it survives a reload — which is the
140
+ * whole reason it is worth reading. It can be stale (a tab left open for a week reads like one
141
+ * opened a minute ago), which is why it ranks last and why its reason is carried, not hidden.
142
+ */
143
+ function initiativesWithTab(tabs) {
144
+ const out = new Set();
145
+ for (const t of Array.isArray(tabs) ? tabs : []) {
146
+ if (t && typeof t.slug === 'string' && t.slug) out.add(t.slug);
147
+ }
148
+ return out;
149
+ }
150
+
151
+ /**
152
+ * THE WORKING SET — the rows the board pins above the Objectives.
153
+ *
154
+ * live {id: [session…]} from sessionsByInitiative
155
+ * trees Set<id> from initiativesWithTree
156
+ * tabs Set<id> from initiativesWithTab
157
+ * onBoard (id) => boolean is this slug on the repository currently showing? REQUIRED, and
158
+ * the reason a tab or a tree belonging to another repository cannot
159
+ * leak a row onto the wrong board.
160
+ *
161
+ * Ordered by attention: needs → blocked → working → quiet → tree → open, alphabetical within a
162
+ * band. So the initiative waiting on the PM is the first row on the screen.
163
+ *
164
+ * Each entry is `{slug, reason, state, sessions}`. `reason` is one of REASONS and is never
165
+ * collapsed away — the surface says which of the three it is.
166
+ */
167
+ function workingSet({ live, trees, tabs, onBoard } = {}) {
168
+ const L = live && typeof live === 'object' ? live : {};
169
+ const T = trees instanceof Set ? trees : new Set();
170
+ const B = tabs instanceof Set ? tabs : new Set();
171
+ const keep = typeof onBoard === 'function' ? onBoard : () => false;
172
+
173
+ const seen = new Map();
174
+ const add = (slug, reason, sessions) => {
175
+ if (!slug || seen.has(slug) || !keep(slug)) return;
176
+ seen.set(slug, { slug, reason, state: reason === 'session' ? stateOf(sessions) : null, sessions: sessions || [] });
177
+ };
178
+ for (const slug of Object.keys(L)) add(slug, 'session', L[slug]);
179
+ for (const slug of T) add(slug, 'tree', null);
180
+ for (const slug of B) add(slug, 'open', null);
181
+
182
+ const attention = (e) => (e.state && SS && SS.ATTENTION ? (SS.ATTENTION[e.state] || 0) : 0);
183
+ return [...seen.values()].sort((a, b) => {
184
+ if (REASON_RANK[a.reason] !== REASON_RANK[b.reason]) return REASON_RANK[b.reason] - REASON_RANK[a.reason];
185
+ if (attention(a) !== attention(b)) return attention(b) - attention(a);
186
+ return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
187
+ });
188
+ }
189
+
190
+ /**
191
+ * What the PM reads when they hover a row's session mark: every live session named INDIVIDUALLY,
192
+ * with the SERVER's own word for its state.
193
+ *
194
+ * The glyph summarises and the tooltip does not — a count would reproduce the status bar's
195
+ * "3 sessions running", which is the thing that says how many and never which. `s.label` is
196
+ * server-rendered (session-state's `label`, one clock, one sentence), so this cannot word a
197
+ * session differently from the pane it belongs to. A row with no label falls back to `shown`
198
+ * rather than inventing a sentence.
199
+ */
200
+ function sessionTitle(slug, sessions) {
201
+ const rows = (sessions || []).filter(isLive);
202
+ if (!rows.length) return '';
203
+ const lines = rows.map((s) => '· ' + (s.name || s.id || 'session') + ' — ' + (s.label || s.shown || 'unknown'));
204
+ const n = rows.length;
205
+ return [
206
+ n + ' live session' + (n === 1 ? '' : 's') + ' on ' + PREFIX + slug,
207
+ ...lines,
208
+ ].join('\n');
209
+ }
210
+
211
+ /** One line naming why an initiative is in the working set. Stated, never implied. */
212
+ function reasonTitle(entry) {
213
+ if (!entry) return '';
214
+ if (entry.reason === 'session') return sessionTitle(entry.slug, entry.sessions);
215
+ if (entry.reason === 'tree') return 'a registered working tree is on ' + PREFIX + entry.slug + ' — nothing is running in it';
216
+ return 'you have a tab open on this initiative in this window';
217
+ }
218
+
219
+ return {
220
+ PREFIX, REASONS, REASON_RANK,
221
+ initiativeOfBranch, isLive, sessionsByInitiative, stateOf,
222
+ initiativesWithTree, initiativesWithTab, workingSet, sessionTitle, reasonTitle,
223
+ };
224
+ });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.23.0",
9
+ "version": "0.24.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.23.0",
3
+ "version": "0.24.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",
@@ -35,6 +35,13 @@
35
35
  Both read this file, so the sentence in the terminal and the sentence in the chooser cannot
36
36
  drift apart. Classic script (a UMD setting window.PMOSTreeShare), like the two above. -->
37
37
  <script src="/lib/tree-share.js"></script>
38
+ <!-- The session→initiative join, made once over every session instead of once per initiative
39
+ (workspace-active-visibility). The SERVER makes the same two-hopper per initiative inside
40
+ /api/initiative (lib/four-panel.js sessionsFor); the BOARD needs it for 178 rows at once and
41
+ cannot spend 178 requests on it. Loaded here rather than imported so it stays walkable in node
42
+ — test.js requires it and walks its whole input domain. It reads window.PMOSSessionState for
43
+ the attention order, so it must come AFTER that script. -->
44
+ <script src="/lib/working-set.js"></script>
38
45
  <!-- The phone's navigation reducer. DOM-free, loaded as a classic script (it is a UMD that sets
39
46
  window.PMOSMobileNav) and required by test.js, so the whole screen domain is walkable in node
40
47
  rather than only in a browser. pm/mobile.js reads it from the global; the module graph does not
@@ -465,6 +465,29 @@ export function branchOfWorkspace(id) {
465
465
  return (id && wsBranch.get(id)) || null;
466
466
  }
467
467
 
468
+ /**
469
+ * The same map as a plain object, for `setState`.
470
+ *
471
+ * `branchOfWorkspace` answers "what branch is THIS workspace on?", which is all the + session
472
+ * chooser ever needed. The initiative board asks the other direction — "which initiatives does this
473
+ * machine have a checkout for, and which of my live sessions is on one?" — and a lookup by id
474
+ * cannot be enumerated.
475
+ *
476
+ * It is PUBLISHED rather than exported as a getter for the reason `productWs` is: a panel that has
477
+ * to import the data layer to render a row cannot render that row until the import resolves, and
478
+ * panel 2 is the board. Published, the fact arrives in the same patch as everything else the panel
479
+ * reads, and the panel needs no adapter at all.
480
+ *
481
+ * `wsBranch` stays the single source — this is a projection of it taken at publish time, not a
482
+ * second copy anything writes to. Nulls are kept out: a folder with no git has no branch, and a map
483
+ * carrying holes would make every reader re-check the same thing.
484
+ */
485
+ function branchMap() {
486
+ const out = {};
487
+ for (const [id, b] of wsBranch) if (typeof b === 'string' && b) out[id] = b;
488
+ return out;
489
+ }
490
+
468
491
  /** Group registered working trees into products: several worktrees of one repo share a `repo_key`. */
469
492
  function foldWorkspaces(rows) {
470
493
  const byRepo = new Map();
@@ -595,7 +618,11 @@ function publishBoards() {
595
618
  board[name] = p.board;
596
619
  records = { ...records, ...p.records };
597
620
  }
598
- setState({ board, productWs, records: { ...records, ...(hydrated) } });
621
+ // `wsBranches` rides the same patch as `productWs` and for the same reason: it is a fact about
622
+ // every registered working tree, read once from /api/workspaces, that a panel needs in order to
623
+ // draw a row. panel 2 joins it against `state.sessions` to say which initiative each live agent is
624
+ // on — the same `initiative/<id>` join lib/four-panel.js makes per initiative on the server.
625
+ setState({ board, productWs, wsBranches: branchMap(), records: { ...records, ...(hydrated) } });
599
626
  }
600
627
 
601
628
  /* Hydrated (fully derived) records live apart from the light ones so that a board reload cannot
@@ -25,6 +25,33 @@
25
25
  * overrides the data layer may publish into state — `state.board` ({repoId: group[]}) and
26
26
  * `state.records` ({slug: record}). Nothing here caches anything derivable (D33): groups, counts,
27
27
  * search hits and the selected row are computed at read time, every render.
28
+ *
29
+ * ── What the board says about YOUR work (workspace-active-visibility) ────────────────────────
30
+ * On 178 records the filing order is the right filing order and the wrong ATTENTION order, and the
31
+ * PM could not tell which initiatives they were working on or which had a live agent. Two additions,
32
+ * both inside the elements that were already here:
33
+ *
34
+ * 1. THE ROW'S LEADING SLOT. It held a constant `#` — the Slack channel glyph, ported for its
35
+ * look, carrying nothing on every row. On an initiative with a live session it now holds that
36
+ * session's glyph, in that session's colour, with every session named in the tooltip. Same
37
+ * span, same node count, same geometry. The TRAILING dot is untouched: it is the ladder's, and
38
+ * `lightRecord` cannot derive 178 of those without ~30s of `git log`.
39
+ *
40
+ * 2. A DERIVED GROUP ABOVE THE OBJECTIVES. Built from `groupNode`, the same element, with the same
41
+ * collapse key — derived groups are not new here (`unanchored` and `no record` are both
42
+ * constructed in data.js). It lists what a live session, a checked-out working tree, or an open
43
+ * tab says the PM is on, ordered so the initiative WAITING on them is the first row.
44
+ *
45
+ * Three rules hold it honest. Rows are DUPLICATED, never moved — an initiative keeps its place under
46
+ * its own anchor, because the anchor is the truth (D59) and a shortcut that re-homes work is a second
47
+ * filing system. The group is ABSENT, not empty, when nothing is live. And the join is
48
+ * lib/working-set.js's, which is the server's own `initiative/<id>` two-hopper — so a session the
49
+ * join cannot reach contributes nothing rather than being bucketed somewhere plausible.
50
+ *
51
+ * Fixture mode is structurally unaffected, not conditionally suppressed: `pollSessions` returns early
52
+ * on FIXTURE and `foldWorkspaces` never runs, so `state.sessions` is empty and there are no branches.
53
+ * There is no server in fixture mode, so there are no live sessions to claim — the same rule line 45
54
+ * states in the other direction.
28
55
  */
29
56
 
30
57
  import { el, setChildren } from './dom.js';
@@ -103,6 +130,75 @@ function sessionsOf(slug, DATA) {
103
130
  return [...(i.sessions || []), ...((state.extraSess && state.extraSess[slug]) || [])];
104
131
  }
105
132
 
133
+ /* ── the working set: which initiatives are yours, and which of them are live ─────────────────── */
134
+
135
+ /** The shared modules, off the globals the way panel-tabs.js reads window.PMOSTreeShare. */
136
+ const WS = () => (typeof window !== 'undefined' ? window.PMOSWorkingSet : null);
137
+ const SS = () => (typeof window !== 'undefined' ? window.PMOSSessionState : null);
138
+
139
+ /** workspace id → branch, published by the data layer alongside `board` and `productWs`. */
140
+ const branches = () => (state.wsBranches && typeof state.wsBranches === 'object' ? state.wsBranches : {});
141
+
142
+ /**
143
+ * Every LIVE session, keyed by the initiative its working tree is on: `{slug: [session…]}`.
144
+ *
145
+ * Fixture mode returns nothing and SAYS so rather than relying on the inputs happening to be empty.
146
+ * `pollSessions` does return early on FIXTURE — but a marker drawn from seed data is "the beautiful
147
+ * shell" in the PRD's own failure-mode list, and that is not a property to leave resting on a
148
+ * `return` two modules away.
149
+ */
150
+ function liveSessions() {
151
+ if (FIXTURE) return {};
152
+ const ws = WS();
153
+ if (!ws) return {};
154
+ const map = branches();
155
+ return ws.sessionsByInitiative(state.sessions, (id) => map[id] || null);
156
+ }
157
+
158
+ /**
159
+ * The rows the derived group shows, in attention order.
160
+ *
161
+ * `onBoard` is what stops a tab or a working tree belonging to ANOTHER repository putting a row on
162
+ * this repository's board — `slugProduct()` is the same map panel 4 scopes its tabs by.
163
+ */
164
+ function workingSet(live, product) {
165
+ if (FIXTURE) return [];
166
+ const ws = WS();
167
+ if (!ws) return [];
168
+ const owner = slugProduct();
169
+ return ws.workingSet({
170
+ live,
171
+ trees: ws.initiativesWithTree(Object.values(branches())),
172
+ tabs: ws.initiativesWithTab(state.tabs),
173
+ onBoard: (slug) => owner[slug] === product,
174
+ });
175
+ }
176
+
177
+ /**
178
+ * The row's leading slot, for one initiative: `['#', colour, title]` when nothing is live, and the
179
+ * session's own glyph/colour/tooltip when something is.
180
+ *
181
+ * The vocabulary is `window.PMOSSessionState`'s — the module index.html loads for exactly this
182
+ * reason ("ONE table, TWO consumers"). Not `FX.SDOT`, which is the design prototype's seed data,
183
+ * parity-locked to prototype-original.html, and which gives `working` and `needs` the same ● and
184
+ * separates them by hue alone. On a board the PM scans, a signal that dies in greyscale is not a
185
+ * signal.
186
+ */
187
+ const HASH = ['#', 'var(--muted-foreground)', ''];
188
+
189
+ function leadOf(slug, live) {
190
+ const rows = live[slug];
191
+ if (!rows || !rows.length) return HASH;
192
+ const ss = SS();
193
+ const ws = WS();
194
+ if (!ss || !ws) return HASH;
195
+ const st = ws.stateOf(rows);
196
+ // A state the table does not know is NOT drawn. Falling back to a generic dot here would put a
197
+ // mark on the board that no vocabulary can explain, which is the one thing worse than no mark.
198
+ if (!st || !ss.GLYPH[st]) return HASH;
199
+ return [ss.GLYPH[st], ss.COLOR[st] || 'var(--muted-foreground)', ws.sessionTitle(slug, rows)];
200
+ }
201
+
106
202
  /** Which repository owns each slug (lines 1963–1965) — the tab-scoping key. */
107
203
  function slugProduct() {
108
204
  const map = {};
@@ -221,8 +317,14 @@ function groupNode(g, product) {
221
317
  onClick: () => setState({ sel: it.slug, detail: { kind: 'init' }, col2: true }),
222
318
  style: 'display:flex;align-items:center;gap:7px;width:100%;padding:5px 12px 5px 27px;min-height:32px;border-left:2px solid ' + it.bl + ';background:' + it.bg,
223
319
  hover: 'background:var(--accent)',
320
+ // Stated where the PM is standing, not only in a tooltip on the glyph: on a working-set row
321
+ // this names WHICH of the three signals put it there. The glyph summarises; this does not.
322
+ title: it.why || null,
224
323
  },
225
- el('span', { style: 'flex:none;font-size:12px;color:var(--muted-foreground)' }, '#'),
324
+ // ① The slot that used to be a constant. `#` when nothing is live — byte-identical to what
325
+ // shipped, which is what keeps the comparator's text axis clean — and the session's own
326
+ // glyph when something is. One span either way: no node added, no geometry moved.
327
+ el('span', { style: 'flex:none;font-size:12px;color:' + it.leadC, title: it.leadT || null }, it.lead),
226
328
  el('span', { style: 'font-family:var(--mono);font-size:12.5px;color:var(--foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(it.slug)),
227
329
  el('span', { style: 'margin-left:auto;flex:none;font-size:9px;color:' + it.l2c, title: it.l2b }, '●'),
228
330
  ));
@@ -238,22 +340,44 @@ function groupNode(g, product) {
238
340
 
239
341
  /* ── "matches elsewhere" — artifacts and sessions that the group list cannot show ──────────── */
240
342
 
241
- function searchHits(q, DATA) {
343
+ /**
344
+ * `searchHits` used to read sessions off `record.sessions` ONLY — and board records are LIGHT, with
345
+ * `sessions: []`, until the PM opens one. So a search for a session's name returned nothing for 177
346
+ * of 178 initiatives, silently, with no indication that the search had not looked. The hit was
347
+ * scoped to hydrated records and nothing said so.
348
+ *
349
+ * `live` closes it: the same join the row markers use, over every session the server has published,
350
+ * regardless of what has been hydrated. The old path is KEPT rather than replaced — it also carries
351
+ * `state.extraSess`, the sessions this window started, which have no server row until the next poll.
352
+ * `seen` is what stops one session arriving twice when both paths find it.
353
+ */
354
+ function searchHits(q, DATA, live) {
242
355
  const hits = [];
243
356
  if (!q) return hits;
357
+ const seen = new Set();
358
+ const push = (slug, s) => {
359
+ const key = slug + '|' + (s.id || s.name);
360
+ if (seen.has(key)) return;
361
+ seen.add(key);
362
+ hits.push({
363
+ kind: 'session · ' + slug,
364
+ label: s.name + ' · ' + (s.label || s.shown || s.st || ''),
365
+ on: () => { setState({ sel: slug, detail: { kind: 'init' } }); openTerm(slug, s); },
366
+ });
367
+ };
244
368
  Object.keys(DATA).forEach((slug) => {
245
369
  (DATA[slug].arts || []).forEach((a) => {
246
370
  if (a.path.toLowerCase().includes(q)) {
247
371
  hits.push({ kind: 'artifact · ' + a.role, label: a.path, on: () => openDoc(slug, a.path, a.role) });
248
372
  }
249
373
  });
250
- sessionsOf(slug, DATA).forEach((s) => {
251
- if (s.name.toLowerCase().includes(q)) {
252
- hits.push({
253
- kind: 'session · ' + slug, label: s.name + ' · ' + s.label,
254
- on: () => { setState({ sel: slug, detail: { kind: 'init' } }); openTerm(slug, s); },
255
- });
256
- }
374
+ sessionsOf(slug, DATA).forEach((s) => { if (s.name.toLowerCase().includes(q)) push(slug, s); });
375
+ });
376
+ Object.keys(live || {}).forEach((slug) => {
377
+ (live[slug] || []).forEach((s) => {
378
+ // The server's row calls the presented state `shown`; a record's row calls it `st`. `openTerm`
379
+ // reads `st`, so the shape is normalised HERE rather than teaching the opener a second one.
380
+ if (s.name && s.name.toLowerCase().includes(q)) push(slug, { ...s, st: s.shown });
257
381
  });
258
382
  });
259
383
  return hits.slice(0, 8);
@@ -398,23 +522,54 @@ export function renderInitiatives(host) {
398
522
  const DATA = records();
399
523
  const selSlug = selectedSlug();
400
524
  const detKind = (state.detail && state.detail.kind) || '';
525
+ // ONE join per render, shared by the row markers, the working-set group and the search. Computing
526
+ // it three times would be three chances to disagree about one fact.
527
+ const live = liveSessions();
528
+
529
+ /** One row's view model. `why` is null everywhere except in the working-set group. */
530
+ const rowOf = (slug, why) => {
531
+ const i = DATA[slug];
532
+ // The prototype reads `i.l2` unguarded; a real repository can hand back a slug with no
533
+ // record at all, and a board that throws is worse than a row that reads "no record".
534
+ const l2 = (i && i.l2) || ['', '', FX.C.mut];
535
+ const [lead, leadC, leadT] = leadOf(slug, live);
536
+ return {
537
+ slug, ...selRow(slug === selSlug && detKind === 'init'),
538
+ l2a: l2[0], l2b: l2[1], l2c: l2[2], lead, leadC, leadT, why: why || null,
539
+ };
540
+ };
401
541
 
402
542
  let total = 0;
403
543
  const groups = groupsFor(product).map((g) => {
404
- const inits = g.slugs.filter((s) => !q || s.includes(q)).map((slug) => {
405
- const i = DATA[slug];
406
- // The prototype reads `i.l2` unguarded; a real repository can hand back a slug with no
407
- // record at all, and a board that throws is worse than a row that reads "no record".
408
- const l2 = (i && i.l2) || ['', '', FX.C.mut];
409
- return { slug, ...selRow(slug === selSlug && detKind === 'init'), l2a: l2[0], l2b: l2[1], l2c: l2[2] };
410
- });
544
+ const inits = g.slugs.filter((s) => !q || s.includes(q)).map((slug) => rowOf(slug, null));
411
545
  total += inits.length;
412
546
  return { ...g, inits, isEmpty: inits.length === 0 };
413
547
  }).filter((g) => g.inits.length > 0 || (g.dyn && !q));
414
548
 
415
- const hits = searchHits(q, DATA);
549
+ const hits = searchHits(q, DATA, live);
416
550
 
417
551
  const kids = [header, searchBox];
552
+ /* THE WORKING SET, pinned above the Objectives.
553
+ *
554
+ * It is `groupNode` — the same element, the same collapse key, the same forced-open-on-search
555
+ * rule — and it is NOT in `groups`, deliberately: it must not be counted in `total`, because
556
+ * `total` decides the "nothing matches" line and a duplicate row is not a second match.
557
+ *
558
+ * `hasKr` is reused to state the derivation. That slot renders an id and a sentence under a group
559
+ * header, which is exactly the shape "what is this group?" needs, and reusing it is what the PM
560
+ * asked for ("use the existing one"). It carries no KR here and claims none.
561
+ *
562
+ * Absent when empty. Not a "0", not a placeholder: a heading over nothing is a heading the PM
563
+ * learns to skip. */
564
+ const now = workingSet(live, product).filter((e) => !q || e.slug.includes(q));
565
+ if (now.length) {
566
+ kids.push(groupNode({
567
+ gid: 'NOW', dyn: true, idc: FX.C.brand, gname: 'working on', gct: String(now.length),
568
+ hasKr: true, krid: '⑂', krname: 'live sessions · checked-out trees · open tabs',
569
+ inits: now.map((e) => rowOf(e.slug, WS() ? WS().reasonTitle(e) : null)),
570
+ isEmpty: false,
571
+ }, product));
572
+ }
418
573
  for (const g of groups) kids.push(groupNode(g, product));
419
574
  kids.push(fileNewRow);
420
575
  if (hits.length) {
@@ -3243,6 +3243,10 @@ async function handleRequest(req, res) {
3243
3243
  if (p === '/lib/mobile-nav.js') return serveFile(res, path.join(__dirname, 'lib', 'mobile-nav.js'));
3244
3244
  if (p === '/lib/session-state.js') return serveFile(res, path.join(__dirname, 'lib', 'session-state.js'));
3245
3245
  if (p === '/lib/tree-share.js') return serveFile(res, path.join(__dirname, 'lib', 'tree-share.js'));
3246
+ // Which initiatives the PM is working on, and which of those are live. The session→initiative join
3247
+ // is the same two-hopper lib/four-panel.js makes per initiative on the server; the board makes it
3248
+ // once over every session, from this module, so 178 rows cost no requests.
3249
+ if (p === '/lib/working-set.js') return serveFile(res, path.join(__dirname, 'lib', 'working-set.js'));
3246
3250
  // The editor computes its live diff with the SAME module the server saves through, so what the PM
3247
3251
  // sees while typing and what gets written cannot disagree. NB: /lib/ is an explicit allowlist, not
3248
3252
  // a directory route — a <script src> for a file missing from this list 404s silently, and the