create-pmos 0.25.0 → 0.26.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,33 @@ 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.26.0
337
+
338
+ **Your sessions now know which initiative they belong to — whatever branch you are on — and each
339
+ initiative shows the agents running on it.** Run `npx create-pmos@latest workspace`; the version bump
340
+ means an existing install auto-upgrades.
341
+
342
+ - **`NOW · working on` stopped missing sessions.** It could only recognise a session by the *branch*
343
+ its folder was on — `initiative/<id>` — which is true of almost no real checkout. Work on `main`,
344
+ or on a branch your agent harness named, and the board could not see the agent you had running.
345
+ A session now **records** which initiative it is for when you start it from one, so it is
346
+ attributed whatever branch you are on, and that survives restarting the workspace.
347
+ - **Nothing is guessed, and nothing is hidden.** Where no initiative was recorded, the
348
+ `initiative/<id>` branch still answers — and the row tells you when the attribution is only a
349
+ branch reading rather than something PMOS was told. A session it genuinely cannot place is listed
350
+ under **not on an initiative** instead of vanishing: an agent that is running should never be
351
+ invisible on the screen whose job is to tell you what is running.
352
+ - **Bind a session that is already running.** `+ bind` records what a session is for — including
353
+ every session that was running before this shipped, and any you started outside PMOS. It is stored
354
+ as **your declaration**, kept distinct from what PMOS was told first-hand, and re-binding tells you
355
+ what it replaced. Binding never touches the process: same session, same folder, still running.
356
+ - **The initiative panel lists its live sessions.** Select an initiative and you see the agents on
357
+ it, each with the same word the sidebar uses — `needs you` · `working` · `quiet 12m` ·
358
+ `maybe blocked` — and its profile.
359
+ - **Click one to get to it.** If its tab is already open, you go to that tab. If not, a tab opens
360
+ **attached** to the running session, scrollback and all. It never starts a second agent — the row
361
+ says which of the two will happen before you click.
362
+
336
363
  ## What's new in 0.24.0
337
364
 
338
365
  **The initiative board says which work is yours, and which of it has an agent running.** Run
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.25.0",
3
+ "version": "0.26.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@bb78e24
1
+ kernel-source: pmos@6f4c86b
2
2
  assembled: 2026-07-31
3
3
  mode: vendor
@@ -68,17 +68,22 @@ bar and a 26px status bar, on the oklch token layer, in both themes:
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
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.
80
- 3. **Initiative detail** — the record's header and pills (type / anchor / flow / branch), the derived
81
- footer, **task phases** (collapsible; a phase's status is derived from its tasks, and the tasks
71
+ something to say: every initiative with a live session, a registered working tree on its
72
+ `initiative/<id>` branch, or a tab open in this window, ordered so whatever is **waiting on you**
73
+ is the first row on the board. The three signals are ranked, never merged, and each row's tooltip
74
+ names which one put it there. On a row with a live session the leading `#` becomes that session's
75
+ own glyph — `◆ needs you`, `● working`, `◇ maybe blocked`, `◼ quiet` — in the vocabulary
76
+ `lib/session-state.js` uses everywhere else, with every session named individually on hover. The
77
+ glyphs differ in **shape**, so the signal survives greyscale.
78
+ **A session records which initiative it is for**, so `+ session` on an initiative is attributed
79
+ to it whatever branch you are on — and that survives a server restart. Where nothing was recorded
80
+ the `initiative/<id>` branch still answers, and the row says when the attribution is only a branch
81
+ reading. A session PMOS cannot place is never guessed at **and never hidden**: it is listed under
82
+ *not on an initiative*, and `+ bind` records what it is for as your own declaration.
83
+ 3. **Initiative detail** — the record's header and pills (type / anchor / flow / branch), the
84
+ **sessions** running on it (each with the same word the sidebar uses — click one to go to its tab
85
+ if it is open, or to open a tab **attached** to the running session; nothing here ever starts an
86
+ agent), the derived footer, **task phases** (collapsible; a phase's status is derived from its tasks, and the tasks
82
87
  are derived from the workflow ladder unless the record authors them), a **links** section for
83
88
  references that are not repo files, and **artifacts** with an external destination and the sync
84
89
  panel. A record that fails to read renders **red with the parse error** — never a blank panel.
@@ -5,25 +5,39 @@
5
5
  // records: *which of these am I currently working on*, and *which one has an active session*. Both
6
6
  // were computable from state the browser already held, and nothing put them together.
7
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:
8
+ // ── THE JOIN ─────────────────────────────────────────────────────────────────────────────────
9
+ // This module shipped joining on ONE signal, because one was all a session carried:
13
10
  //
14
11
  // session ──s.ws──▶ workspace ──w.branch──▶ "initiative/<id>" ──▶ the initiative
15
12
  //
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.
13
+ // That is the join lib/four-panel.js `sessionsFor()` makes per initiative on the server, made once
14
+ // over ALL sessions instead, so the board can colour 178 rows without 178 requests.
15
+ //
16
+ // It was too narrow, and the proof was this repository's own history: the initiative that shipped
17
+ // that board was built on a `claude/*` branch, so the feature could not see the session that wrote
18
+ // it. The convention holds in one of four ordinary situations — the PM works on `main`, the harness
19
+ // opens sessions on `claude/*`, plenty of repos have no git at all, and only a worktree made by
20
+ // `+ session`'s branch control is actually named after its initiative.
21
+ //
22
+ // So a session now RECORDS what it is for (workspace-session-attribution), and attribution runs in
23
+ // precedence order — recorded, then branch, then unattributed — with the answering path carried so
24
+ // an inference is never mistaken for a record. See `attributeSessions`.
25
+ //
26
+ // What is unchanged: this module runs no git and guesses at nothing. It reads the branch strings the
27
+ // server derived (`git rev-parse --abbrev-ref`, published on /api/workspaces) and the session rows
28
+ // the server published, and nothing else.
20
29
  //
21
30
  // ── 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.
31
+ // Every input that does not resolve contributes NOTHING TO AN INITIATIVE. A session whose workspace
32
+ // is unknown, a workspace with no branch, a branch that is not `initiative/…`, a branch that is the
33
+ // literal `initiative/` with no id — none of them lands on a row. A marker on the wrong initiative
34
+ // is worse than no marker: the PM would stop trusting the column, and an untrusted signal costs more
35
+ // than an absent one.
36
+ //
37
+ // It is NOT dropped, though — that was the other half of the same defect. An unresolvable live
38
+ // session is returned as `unattributed`, because a running agent that appears nowhere is invisible
39
+ // on the one surface whose job is to say what is running. Refusing to guess and refusing to mention
40
+ // are different things, and only the first is honesty.
27
41
  //
28
42
  // `exited` is not live. A session that has ended is a corpse, not a claim on anyone's attention, and
29
43
  // it is filtered before the loudest-state fold rather than ranked at the bottom of it.
@@ -92,18 +106,63 @@
92
106
  * never containing an initiative the join could not actually reach.
93
107
  */
94
108
  function sessionsByInitiative(sessions, branchOf) {
95
- const out = Object.create(null);
96
- if (typeof branchOf !== 'function') return out;
109
+ return attributeSessions(sessions, branchOf).byInitiative;
110
+ }
111
+
112
+ /**
113
+ * ATTRIBUTION, in precedence order, with the answering path carried.
114
+ *
115
+ * The first cut of this module joined on the branch alone, because that was the only signal a
116
+ * session carried. It is no longer: `sessionList()` publishes `initiative` + `attributed_by` for
117
+ * a session PMOS was told about at spawn, or that the PM bound afterwards. So the order is:
118
+ *
119
+ * 1. `s.initiative` RECORDED — PMOS was told, or the PM said so. First-hand.
120
+ * 2. `initiative/<id>` BRANCH — an inference from a naming convention. Kept, because every
121
+ * session running before the field existed has only this; labelled,
122
+ * because an inference must never be mistaken for a record.
123
+ * 3. neither UNATTRIBUTED — reported as such, never bucketed or guessed.
124
+ *
125
+ * Why the branch survives at all, since a recorded field is strictly better: dropping it would
126
+ * un-attribute every session on a real `initiative/<id>` worktree the moment this shipped, which
127
+ * is a regression the PM would experience as the feature losing information it used to have. It
128
+ * is a fallback with a name, not a second opinion competing with the record — `via` says which
129
+ * answered, and the two never merge.
130
+ *
131
+ * Returns `{ byInitiative: {id: [session…]}, via: {sessionId: 'recorded'|'branch'}, unattributed:
132
+ * [session…] }`. Insertion-ordered, no empty arrays, and nothing the join could not actually reach.
133
+ */
134
+ function attributeSessions(sessions, branchOf) {
135
+ const byInitiative = Object.create(null);
136
+ const via = Object.create(null);
137
+ const unattributed = [];
138
+ const lookup = typeof branchOf === 'function' ? branchOf : null;
97
139
  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);
140
+ if (!isLive(s)) continue;
141
+ // 1 — the record. Independent of `ws`: a session in a folder with no git, or one whose
142
+ // workspace the client has not loaded, is still attributable if PMOS was told what it is for.
143
+ let id = validId(s.initiative);
144
+ let how = id ? 'recorded' : null;
145
+ // 2 — the branch, only where the record is silent. A lookup that THROWS must not take the
146
+ // board down with it: the map is the data layer's and this module does not own its failures.
147
+ if (!id && s.ws && lookup) {
148
+ try { id = initiativeOfBranch(lookup(s.ws)); } catch { id = null; }
149
+ if (id) how = 'branch';
150
+ }
151
+ if (!id) { unattributed.push(s); continue; }
152
+ (byInitiative[id] || (byInitiative[id] = [])).push(s);
153
+ if (s.id) via[s.id] = how;
105
154
  }
106
- return out;
155
+ return { byInitiative, via, unattributed };
156
+ }
157
+
158
+ /**
159
+ * Is this a usable initiative id? Deliberately permissive about SHAPE and strict about type —
160
+ * the server has already validated anything it recorded (`fourPanel.validId`), and re-deriving
161
+ * that regex here would put a second opinion in the tree about what an id is. What this rejects
162
+ * is the shapes that cannot be a key at all.
163
+ */
164
+ function validId(v) {
165
+ return typeof v === 'string' && v !== '' && v.indexOf('/') === -1 ? v : null;
107
166
  }
108
167
 
109
168
  /**
@@ -164,16 +223,26 @@
164
223
  * Each entry is `{slug, reason, state, sessions}`. `reason` is one of REASONS and is never
165
224
  * collapsed away — the surface says which of the three it is.
166
225
  */
167
- function workingSet({ live, trees, tabs, onBoard } = {}) {
226
+ function workingSet({ live, trees, tabs, onBoard, via } = {}) {
168
227
  const L = live && typeof live === 'object' ? live : {};
169
228
  const T = trees instanceof Set ? trees : new Set();
170
229
  const B = tabs instanceof Set ? tabs : new Set();
171
230
  const keep = typeof onBoard === 'function' ? onBoard : () => false;
231
+ const how = via && typeof via === 'object' ? via : {};
172
232
 
173
233
  const seen = new Map();
174
234
  const add = (slug, reason, sessions) => {
175
235
  if (!slug || seen.has(slug) || !keep(slug)) return;
176
- seen.set(slug, { slug, reason, state: reason === 'session' ? stateOf(sessions) : null, sessions: sessions || [] });
236
+ // WHICH PATH ANSWERED, carried onto the row so the reason line can say it. An initiative whose
237
+ // sessions were all resolved from a branch name is a weaker claim than one PMOS was told
238
+ // about, and the PM is entitled to know which they are looking at.
239
+ const rows = sessions || [];
240
+ const paths = rows.map((s) => (s && s.id ? how[s.id] : null)).filter(Boolean);
241
+ seen.set(slug, {
242
+ slug, reason, state: reason === 'session' ? stateOf(sessions) : null, sessions: rows,
243
+ via: paths.length && paths.every((v) => v === 'branch') ? 'branch'
244
+ : paths.includes('recorded') ? 'recorded' : null,
245
+ });
177
246
  };
178
247
  for (const slug of Object.keys(L)) add(slug, 'session', L[slug]);
179
248
  for (const slug of T) add(slug, 'tree', null);
@@ -211,14 +280,41 @@
211
280
  /** One line naming why an initiative is in the working set. Stated, never implied. */
212
281
  function reasonTitle(entry) {
213
282
  if (!entry) return '';
214
- if (entry.reason === 'session') return sessionTitle(entry.slug, entry.sessions);
283
+ if (entry.reason === 'session') {
284
+ const t = sessionTitle(entry.slug, entry.sessions);
285
+ // A branch-only attribution says so. It is a reading of a folder's branch name, not something
286
+ // PMOS was told, and the difference is the whole point of the recorded field.
287
+ return entry.via === 'branch'
288
+ ? t + '\nattributed by the ' + PREFIX + entry.slug + ' branch — no session recorded this initiative'
289
+ : t;
290
+ }
215
291
  if (entry.reason === 'tree') return 'a registered working tree is on ' + PREFIX + entry.slug + ' — nothing is running in it';
216
292
  return 'you have a tab open on this initiative in this window';
217
293
  }
218
294
 
295
+ /**
296
+ * The tooltip for the unattributed row: every live session PMOS cannot place, named individually.
297
+ *
298
+ * This row exists because the alternative is worse. A session the join cannot reach has nowhere to
299
+ * go on an initiative-keyed board, and dropping it would mean a running agent is invisible on the
300
+ * surface whose job is to say what is running. It claims NO initiative — the wording has to leave
301
+ * the PM in no doubt that PMOS does not know, rather than implying a default.
302
+ */
303
+ function unattributedTitle(sessions) {
304
+ const rows = (sessions || []).filter(isLive);
305
+ if (!rows.length) return '';
306
+ const n = rows.length;
307
+ return [
308
+ n + ' live session' + (n === 1 ? '' : 's') + ' PMOS cannot place on an initiative',
309
+ ...rows.map((s) => '· ' + (s.name || s.id || 'session') + ' — ' + (s.label || s.shown || 'unknown')),
310
+ 'no initiative was recorded, and no working tree is on an ' + PREFIX + '… branch',
311
+ ].join('\n');
312
+ }
313
+
219
314
  return {
220
315
  PREFIX, REASONS, REASON_RANK,
221
- initiativeOfBranch, isLive, sessionsByInitiative, stateOf,
316
+ initiativeOfBranch, isLive, sessionsByInitiative, attributeSessions, stateOf,
222
317
  initiativesWithTree, initiativesWithTab, workingSet, sessionTitle, reasonTitle,
318
+ unattributedTitle,
223
319
  };
224
320
  });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.25.0",
9
+ "version": "0.26.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.25.0",
3
+ "version": "0.26.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",
@@ -957,6 +957,29 @@ export function startSession(slug, sess) {
957
957
  setTimeout(pollSessions, 800);
958
958
  }
959
959
 
960
+ /**
961
+ * Bind a RUNNING session to an initiative — the PM's own word about work already in flight
962
+ * (workspace-session-attribution R3).
963
+ *
964
+ * Every session that existed before the recorded field shipped has no attribution, and so does every
965
+ * session started outside PMOS. Without this the field would be a promise about future sessions and
966
+ * no help at all with the ones on screen.
967
+ *
968
+ * What comes back is the server's answer, not an assumption: `previous` names the initiative this
969
+ * replaced, so a re-bind the PM did not mean to make is visible rather than silent. Pass
970
+ * `initiative: null` to clear a binding — the honest way back from a wrong one.
971
+ *
972
+ * The poll is scheduled rather than the state patched locally: `/api/sessions` is the one reader of
973
+ * this fact, and writing a second opinion into `state.sessions` here is how the board and the server
974
+ * would come to disagree about what a session is for.
975
+ */
976
+ export async function bindSessionInitiative(id, initiative) {
977
+ if (FIXTURE) return { ok: false, error: 'fixture mode has no server' };
978
+ const out = await postJson('/api/session/initiative', { id, initiative: initiative || null });
979
+ if (out && out.ok) setTimeout(pollSessions, 200);
980
+ return out || { ok: false, error: 'the workspace server did not answer' };
981
+ }
982
+
960
983
  /* ── the scratchpad ─────────────────────────────────────────────────────────────────────────────
961
984
  * The pad is PER SESSION, and the server has always stored it that way: `/api/scratchpad` is keyed
962
985
  * by the server-issued session id (workspace-scoped, one file per session, deleted with it). What
@@ -52,7 +52,7 @@
52
52
  import { el, setChildren } from './dom.js';
53
53
  import { state, setState, FIXTURE } from './state.js';
54
54
  import * as FX from './fixture.js';
55
- import { openDoc, openTab, fileNewModel } from './panel-initiatives.js';
55
+ import { openDoc, openTab, openTerm, fileNewModel } from './panel-initiatives.js';
56
56
  import { addProductBody } from './panel-repos.js';
57
57
 
58
58
  /* ── the prototype's small helpers ─────────────────────────────────────────────────────────── */
@@ -212,6 +212,78 @@ const stickyHeader = el('div', {
212
212
  }, '⟨'),
213
213
  );
214
214
 
215
+ /* ── 1b · SESSIONS — the agents running on THIS initiative ──────────────────────────────────────
216
+ *
217
+ * The PM's report: *"when I click the initiative on the initiative panel (which is column number 3),
218
+ * I can see the active sessions list, then when i click, either go to the tab (if tab is open), or
219
+ * open new tab and directly resume to the session."*
220
+ *
221
+ * Their first sentence was not that the list is unclickable — there was no list. `data.js` has been
222
+ * building `rec.sessions` (each row carrying the server's own id and rendered label) and `rec.noSess`
223
+ * since the four-panel port, and `initBody()` rendered header → phases → links → artifacts → footer.
224
+ * Neither field appeared anywhere in this file. The join ran on the server, survived the wire, was
225
+ * normalised by the data layer, and was dropped by the renderer.
226
+ *
227
+ * ── WHY THIS DOES NOT RENDER `rec.sessions` ──────────────────────────────────────────────────
228
+ * Because `FX.DATA` carries seed `sessions` arrays, and drawing them would light up seven fixture
229
+ * rows and break the design comparator's text axis on the first render. The board solved this the
230
+ * same way and for the same reason: read the LIVE join, which fixture mode structurally cannot fill
231
+ * (`pollSessions` returns early on FIXTURE). There is no server in fixture mode, so there are no
232
+ * live sessions to claim.
233
+ *
234
+ * ── THE CLICK ────────────────────────────────────────────────────────────────────────────────
235
+ * `openTerm` with the session's SERVER id and no `spawn`. Those two facts are the whole behaviour:
236
+ * `openTab` focuses a tab whose id already exists rather than duplicating it, and the absent `spawn`
237
+ * is what withholds authorisation to create a PTY — so a click either lands on the open tab or opens
238
+ * one attached to the running process. It cannot start a second agent.
239
+ */
240
+
241
+ /** The live sessions on one initiative, from the shared join — never from the record's seed rows. */
242
+ function liveSessionsFor(slug) {
243
+ if (FIXTURE || !slug) return [];
244
+ const ws = typeof window !== 'undefined' ? window.PMOSWorkingSet : null;
245
+ const map = state.wsBranches && typeof state.wsBranches === 'object' ? state.wsBranches : {};
246
+ if (!ws || typeof ws.attributeSessions !== 'function') return [];
247
+ const attr = ws.attributeSessions(state.sessions, (id) => map[id] || null);
248
+ return attr.byInitiative[slug] || [];
249
+ }
250
+
251
+ function sessionsBlock(slug) {
252
+ const rows = liveSessionsFor(slug);
253
+ if (!rows.length) return null; // nothing running: no heading over nothing
254
+ const ss = typeof window !== 'undefined' ? window.PMOSSessionState : null;
255
+ const openIds = new Set((state.tabs || []).filter((t) => t && t.kind === 'term' && t.sessionId).map((t) => t.sessionId));
256
+
257
+ return el('div', { style: 'padding:9px 14px 10px;border-bottom:1px solid var(--border)' },
258
+ el('div', { style: 'font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground);margin-bottom:6px' },
259
+ 'sessions'),
260
+ rows.map((s) => {
261
+ const st = ss && ss.GLYPH[s.shown] ? s.shown : null;
262
+ const here = openIds.has(s.id);
263
+ return el('button', {
264
+ onClick: () => openTerm(slug, { name: s.name, st: s.shown, label: s.label, id: s.id, profile: s.profile_id }),
265
+ style: 'display:flex;align-items:center;gap:8px;width:100%;padding:5px 6px;min-height:30px',
266
+ hover: 'background:var(--accent)',
267
+ // What the click will do, said BEFORE it — the difference between landing on a tab already
268
+ // open and opening one that attaches. Neither ever starts an agent, and that is worth saying
269
+ // on the control rather than only in a comment nobody reading the surface can see.
270
+ title: (s.name || s.id) + ' — ' + (s.label || s.shown || '')
271
+ + (s.profile_id ? '\nprofile ' + s.profile_id : '')
272
+ + (here ? '\nits tab is open — this goes to it' : '\nopens a tab attached to this running session; nothing is started'),
273
+ },
274
+ el('span', { style: 'flex:none;font-size:11px;color:' + (st && ss ? ss.COLOR[st] : 'var(--muted-foreground)') },
275
+ st && ss ? ss.GLYPH[st] : '·'),
276
+ el('span', { style: 'font-family:var(--mono);font-size:11.5px;color:var(--foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' },
277
+ iv(s.name || s.id)),
278
+ el('span', { style: 'margin-left:auto;flex:none;font-size:10px;color:var(--muted-foreground)' },
279
+ iv(s.label || s.shown || '')),
280
+ el('span', { style: 'flex:none;font-family:var(--mono);font-size:9.5px;color:var(--muted-foreground)' },
281
+ iv(here ? 'open' : 'resume')),
282
+ );
283
+ }),
284
+ );
285
+ }
286
+
215
287
  /* ── 1 · HEADER — slug, tag, pills (lines 338–346) ──────────────────────────────────────────── */
216
288
 
217
289
  function headerBlock(name, tag, pills) {
@@ -908,6 +980,11 @@ function initBody(slug) {
908
980
  out.push(...noLadderBlock(FX.wash(ansC), ansC, (rec.answer && rec.answer.text) || '', rec.noLadder));
909
981
  }
910
982
 
983
+ // What is RUNNING on this initiative, above the ladder: the loop position is the slower fact, and
984
+ // an agent waiting on the PM is the one they came to the panel for.
985
+ const sess = sessionsBlock(slug);
986
+ if (sess) out.push(sess);
987
+
911
988
  const phases = phaseBlock(slug, rec);
912
989
  if (phases) out.push(phases);
913
990
 
@@ -961,6 +1038,97 @@ function anchorButton(an) {
961
1038
  );
962
1039
  }
963
1040
 
1041
+ /* ── BIND — the PM's own word about a session already running ──────────────────────────────────
1042
+ *
1043
+ * Every session that existed before a session could record its initiative has no attribution, and so
1044
+ * does every session started outside PMOS. This is the only way one of them ever joins an initiative,
1045
+ * which is why "show them read-only" was not enough.
1046
+ *
1047
+ * The whole surface turns on one distinction, stated in the copy rather than left to a code comment:
1048
+ * what is recorded here is a DECLARATION. PMOS was not told at spawn and did not read it off
1049
+ * anything — the PM is saying so. `initiative-links.jsonl` already draws that line with
1050
+ * `origin`/`state`, and a binding that presented itself as a reading would be the same watermelon
1051
+ * this repo keeps finding.
1052
+ */
1053
+ function bindBody() {
1054
+ const out = [];
1055
+ const rows = (state.sessions || []).filter((s) => s && s.shown !== 'exited');
1056
+ const picked = state.bindSess && rows.find((s) => s.id === state.bindSess);
1057
+ const board = state.board && state.board[state.product] ? state.board[state.product] : [];
1058
+ const slugs = [];
1059
+ for (const g of board) for (const sl of g.slugs || []) if (!slugs.includes(sl)) slugs.push(sl);
1060
+ const typed = String(state.bindTo || '');
1061
+ const matches = typed ? slugs.filter((sl) => sl.includes(typed.toLowerCase())).slice(0, 8) : [];
1062
+
1063
+ out.push(el('div', { style: 'padding:13px 14px 11px;border-bottom:1px solid var(--border)' },
1064
+ el('div', { style: 'font-size:14px;font-weight:600;margin-bottom:4px' }, 'bind a session'),
1065
+ el('div', { style: 'font-size:11.5px;line-height:1.5;color:var(--muted-foreground)' },
1066
+ 'PMOS was never told what these sessions are for, and nothing here can read it. '
1067
+ + 'What you pick is recorded as YOUR declaration — not as something PMOS observed.')));
1068
+
1069
+ out.push(el('div', { style: 'padding:10px 14px 2px;' + NEW_LABEL }, 'session'));
1070
+ if (!rows.length) {
1071
+ out.push(el('div', { style: 'padding:2px 14px 10px;font-size:11.5px;color:var(--muted-foreground);font-style:italic' },
1072
+ 'no live sessions — a successful read of nothing'));
1073
+ return out;
1074
+ }
1075
+ out.push(el('div', { style: 'display:flex;flex-direction:column;gap:2px;padding:2px 14px 6px' },
1076
+ rows.map((s) => el('button', {
1077
+ onClick: () => setState({ bindSess: s.id }),
1078
+ style: 'display:flex;gap:8px;align-items:center;min-height:32px;padding:0 9px;background:'
1079
+ + (picked && picked.id === s.id ? 'var(--secondary)' : 'transparent')
1080
+ + ';border:1px solid ' + (picked && picked.id === s.id ? 'var(--input)' : 'var(--border)'),
1081
+ hover: 'border-color:var(--ring)',
1082
+ },
1083
+ el('span', { style: 'font-family:var(--mono);font-size:11px;color:var(--foreground)' }, iv(s.name || s.id)),
1084
+ el('span', { style: 'font-size:11px;color:var(--muted-foreground)' }, iv(s.label || s.shown || '')),
1085
+ // What it is bound to NOW, and by what. A session already attributed can be re-bound, and the
1086
+ // PM is entitled to see what they would be replacing before they replace it (R8).
1087
+ s.initiative
1088
+ ? el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:9.5px;color:var(--muted-foreground)' },
1089
+ iv('→ ' + s.initiative + (s.attributed_by === 'declared' ? ' · declared' : ' · recorded')))
1090
+ : null,
1091
+ ))));
1092
+
1093
+ out.push(el('div', { style: 'padding:6px 14px 2px;' + NEW_LABEL }, 'initiative'));
1094
+ out.push(el('div', { style: 'padding:2px 14px 4px' },
1095
+ newField('bindTo', typed, () => setState({}), 'type part of an initiative id',
1096
+ 'width:100%;font-family:var(--mono);font-size:11px;padding:5px 7px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)')));
1097
+ if (matches.length) {
1098
+ out.push(el('div', { style: 'display:flex;flex-direction:column;gap:2px;padding:2px 14px 6px' },
1099
+ matches.map((sl) => el('button', {
1100
+ onClick: () => {
1101
+ if (!picked) return;
1102
+ const data = sibling('data', './data.js');
1103
+ if (!data || typeof data.bindSessionInitiative !== 'function') return;
1104
+ data.bindSessionInitiative(picked.id, sl).then((r) => {
1105
+ setState({
1106
+ bindResult: r && r.ok
1107
+ ? '“' + (picked.name || picked.id) + '” is now on ' + sl
1108
+ + (r.previous ? ' — replacing ' + r.previous : '')
1109
+ : 'could not bind — ' + ((r && r.error) || 'the server did not answer'),
1110
+ });
1111
+ });
1112
+ },
1113
+ style: 'display:flex;gap:8px;align-items:center;min-height:30px;padding:0 9px;background:transparent;border:1px solid var(--border)',
1114
+ hover: 'border-color:var(--ring)',
1115
+ }, el('span', { style: 'font-family:var(--mono);font-size:11px' }, iv(sl))))));
1116
+ } else if (typed) {
1117
+ out.push(el('div', { style: 'padding:2px 14px 8px;font-size:11.5px;color:var(--muted-foreground);font-style:italic' },
1118
+ 'nothing on this board matches — a successful read of nothing'));
1119
+ }
1120
+
1121
+ if (!picked) {
1122
+ out.push(el('div', { style: 'padding:4px 14px 10px;font-size:11.5px;color:var(--muted-foreground)' },
1123
+ 'pick a session first.'));
1124
+ }
1125
+ if (state.bindResult) {
1126
+ out.push(el('div', { style: 'margin:4px 14px 10px;padding:8px 11px;border:1px solid var(--border);font-size:11.5px;line-height:1.5' },
1127
+ iv(state.bindResult)));
1128
+ }
1129
+ return out;
1130
+ }
1131
+
964
1132
  function fileNewBody() {
965
1133
  const nw = fileNewModel();
966
1134
  const out = [];
@@ -1073,11 +1241,15 @@ function modeDetailBody(kind) {
1073
1241
 
1074
1242
  export function renderDetail(host) {
1075
1243
  const kind = (state.detail && state.detail.kind) || 'init';
1076
- headerTitleText.textContent = FX.detailTitle(kind);
1244
+ // `bind` is not one of the prototype's detail kinds, and `fixture.js` is its seed data — adding a
1245
+ // key there to title one screen would be drift into a file the parity suite exists to keep
1246
+ // transcribed. The override lives here instead, where the screen does.
1247
+ headerTitleText.textContent = kind === 'bind' ? 'bind a session' : FX.detailTitle(kind);
1077
1248
 
1078
1249
  let body;
1079
1250
  if (kind === 'init') body = initBody(selectedSlug());
1080
1251
  else if (kind === 'addprod') body = addProductBody();
1252
+ else if (kind === 'bind') body = bindBody();
1081
1253
  else if (kind === 'new') body = fileNewBody();
1082
1254
  else if (kind === 'none') body = [noneBody()];
1083
1255
  else body = modeDetailBody(kind);
@@ -45,8 +45,21 @@
45
45
  * Three rules hold it honest. Rows are DUPLICATED, never moved — an initiative keeps its place under
46
46
  * its own anchor, because the anchor is the truth (D59) and a shortcut that re-homes work is a second
47
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.
48
+ * lib/working-set.js's — so a session the join cannot reach never lands on a row it does not belong
49
+ * to.
50
+ *
51
+ * ── What that first cut got wrong (workspace-session-attribution) ────────────────────────────
52
+ * It joined on the `initiative/<id>` branch convention ALONE, which holds in one of four ordinary
53
+ * situations — and the proof was this file's own history: the initiative that shipped this group was
54
+ * built on a `claude/*` branch, so the group could not see the session that wrote it. A session now
55
+ * RECORDS what it is for, and `attributeSessions` resolves recorded → branch → unattributed, saying
56
+ * which path answered.
57
+ *
58
+ * The other half was worse than a narrow join. A session the board could not place was DROPPED, so a
59
+ * running agent was invisible on the surface whose job is to say what is running. Refusing to guess
60
+ * and refusing to mention are different things, and only the first is honesty — hence `orphanRows`,
61
+ * which lists them under the group, claims no initiative for them, and offers the bind that turns
62
+ * the PM's knowledge into a recorded declaration.
50
63
  *
51
64
  * Fixture mode is structurally unaffected, not conditionally suppressed: `pollSessions` returns early
52
65
  * on FIXTURE and `foldWorkspaces` never runs, so `state.sessions` is empty and there are no branches.
@@ -148,11 +161,26 @@ const branches = () => (state.wsBranches && typeof state.wsBranches === 'object'
148
161
  * `return` two modules away.
149
162
  */
150
163
  function liveSessions() {
151
- if (FIXTURE) return {};
164
+ return attribution().byInitiative;
165
+ }
166
+
167
+ /**
168
+ * The full attribution — `{byInitiative, via, unattributed}` — computed ONCE per render and shared
169
+ * by the row markers, the working-set group, the unattributed row and the search. Deriving it three
170
+ * times would be three chances to disagree about one fact.
171
+ *
172
+ * The join now prefers what a session RECORDED over what its branch is called; `via` says which
173
+ * answered, and `unattributed` is every live session neither path could place. That last list is
174
+ * why this returns the whole shape rather than just the map: a running agent PMOS cannot attribute
175
+ * still has to be visible somewhere, and dropping it was half the defect this fixes.
176
+ */
177
+ function attribution() {
178
+ const empty = { byInitiative: {}, via: {}, unattributed: [] };
179
+ if (FIXTURE) return empty;
152
180
  const ws = WS();
153
- if (!ws) return {};
181
+ if (!ws || typeof ws.attributeSessions !== 'function') return empty;
154
182
  const map = branches();
155
- return ws.sessionsByInitiative(state.sessions, (id) => map[id] || null);
183
+ return ws.attributeSessions(state.sessions, (id) => map[id] || null);
156
184
  }
157
185
 
158
186
  /**
@@ -161,13 +189,14 @@ function liveSessions() {
161
189
  * `onBoard` is what stops a tab or a working tree belonging to ANOTHER repository putting a row on
162
190
  * this repository's board — `slugProduct()` is the same map panel 4 scopes its tabs by.
163
191
  */
164
- function workingSet(live, product) {
192
+ function workingSet(attr, product) {
165
193
  if (FIXTURE) return [];
166
194
  const ws = WS();
167
195
  if (!ws) return [];
168
196
  const owner = slugProduct();
169
197
  return ws.workingSet({
170
- live,
198
+ live: attr.byInitiative,
199
+ via: attr.via,
171
200
  trees: ws.initiativesWithTree(Object.values(branches())),
172
201
  tabs: ws.initiativesWithTab(state.tabs),
173
202
  onBoard: (slug) => owner[slug] === product,
@@ -224,9 +253,18 @@ export function openTab(tab) {
224
253
  }
225
254
 
226
255
  export function openTerm(slug, sess) {
256
+ /* THE TAB ID, and why a session with no initiative gets a different one.
257
+ *
258
+ * `term:<slug>:<name>` is the id every initiative-opened terminal has always had, and it is what
259
+ * makes `openTab` focus an already-open tab instead of duplicating it. A session PMOS cannot place
260
+ * on an initiative has no slug — and interpolating one anyway produced `term:null:agent 1`, where
261
+ * every unattributed session with the same name would collide on one tab. Keying those by the
262
+ * SERVER's own session id instead is both collision-free and more honest: the tab is a view of
263
+ * that process, not of an initiative it does not belong to. */
264
+ const key = slug ? 'term:' + slug + ':' + sess.name : 'term:sess:' + ((sess && sess.id) || sess.name);
227
265
  openTab({
228
- id: 'term:' + slug + ':' + sess.name, kind: 'term', title: sess.name, slug,
229
- repo: slugProduct()[slug], sess: sess.name, state: sess.st, profile: sess.profile || 'claude-work',
266
+ id: key, kind: 'term', title: sess.name, slug: slug || null,
267
+ repo: slug ? slugProduct()[slug] : null, sess: sess.name, state: sess.st, profile: sess.profile || 'claude-work',
230
268
  /* The SERVER's session id, when the row that was clicked carries one. This is the exact join:
231
269
  * with it, the terminal attaches to precisely the PTY the PM pointed at, and a reload attaches
232
270
  * to the same one again. Without it the tab falls back to matching by NAME, and names repeat
@@ -333,11 +371,67 @@ function groupNode(g, product) {
333
371
  body.push(el('div', { style: 'padding:5px 12px 6px 27px;font-size:11px;color:var(--muted-foreground);font-style:italic' },
334
372
  'nothing filed yet — a successful read of nothing'));
335
373
  }
374
+ // Rows this group carries that are NOT initiatives — today, the live sessions PMOS cannot place.
375
+ // Appended by the caller so `groupNode` stays the one renderer and does not learn a second model.
376
+ if (g.after) body.push(g.after);
336
377
  }
337
378
 
338
379
  return el('div', { style: 'margin-top:4px' }, groupHeader, body);
339
380
  }
340
381
 
382
+ /* ── the live sessions PMOS cannot place ──────────────────────────────────────────────────────
383
+ *
384
+ * One row per unattributable live session, under the working-set group, in the same row shape the
385
+ * initiatives use so the eye reads one list. It deliberately does NOT look like an initiative row:
386
+ * the id slot carries the session's own name and its glyph is the session state, because this row
387
+ * stands for a process, not for a record.
388
+ *
389
+ * Clicking it opens that session's terminal — the same `openTerm` join every other session click
390
+ * uses, by the server's id, with no `spawn`. `+ bind` is the second control: it turns the PM's
391
+ * knowledge of what the session is for into a recorded declaration, which is the only way a session
392
+ * started before this field existed can ever join an initiative.
393
+ */
394
+ function orphanRows(rows) {
395
+ const ss = SS();
396
+ const ws = WS();
397
+ const head = el('div', {
398
+ style: 'display:flex;align-items:baseline;gap:6px;padding:6px 12px 3px 27px',
399
+ title: ws ? ws.unattributedTitle(rows) : null,
400
+ },
401
+ el('span', { style: 'flex:none;font-family:var(--mono);font-size:10px;color:var(--warning)' }, iv('⌁')),
402
+ el('span', { style: 'font-size:10.5px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' },
403
+ iv('not on an initiative — ' + rows.length + ' live session' + (rows.length === 1 ? '' : 's'))),
404
+ );
405
+
406
+ const body = rows.map((s) => {
407
+ const st = ss && ss.GLYPH[s.shown] ? s.shown : null;
408
+ return el('button', {
409
+ onClick: () => openTerm(null, { name: s.name, st: s.shown, label: s.label, id: s.id, profile: s.profile_id }),
410
+ style: 'display:flex;align-items:center;gap:7px;width:100%;padding:5px 12px 5px 27px;min-height:32px;border-left:2px solid transparent;background:transparent',
411
+ hover: 'background:var(--accent)',
412
+ title: (s.name || s.id) + ' — ' + (s.label || s.shown || '')
413
+ + '\nPMOS was never told what this session is for. + bind records it.',
414
+ },
415
+ el('span', { style: 'flex:none;font-size:12px;color:' + (st && ss ? ss.COLOR[st] : 'var(--muted-foreground)') },
416
+ st && ss ? ss.GLYPH[st] : '·'),
417
+ el('span', { style: 'font-family:var(--mono);font-size:12.5px;color:var(--foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' },
418
+ iv(s.name || s.id)),
419
+ el('span', { style: 'margin-left:auto;flex:none;font-size:9px;color:var(--muted-foreground)' }, iv(s.label || s.shown || '')),
420
+ );
421
+ });
422
+
423
+ const bind = el('button', {
424
+ onClick: () => setState({ bindSess: rows.length === 1 ? rows[0].id : null, bindResult: null, detail: { kind: 'bind' }, col2: true }),
425
+ style: 'display:flex;align-items:center;gap:7px;width:100%;padding:6px 12px 6px 27px;color:var(--muted-foreground)',
426
+ hover: 'color:var(--foreground);background:var(--accent)',
427
+ },
428
+ el('span', { style: 'flex:none;width:9px;text-align:center;font-size:14px' }, '+'),
429
+ el('span', { style: 'font-size:11px' }, 'bind a session to an initiative'),
430
+ );
431
+
432
+ return el('div', null, head, body, bind);
433
+ }
434
+
341
435
  /* ── "matches elsewhere" — artifacts and sessions that the group list cannot show ──────────── */
342
436
 
343
437
  /**
@@ -522,9 +616,10 @@ export function renderInitiatives(host) {
522
616
  const DATA = records();
523
617
  const selSlug = selectedSlug();
524
618
  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();
619
+ // ONE attribution per render, shared by the row markers, the working-set group, the unattributed
620
+ // row and the search. Computing it four times would be four chances to disagree about one fact.
621
+ const attr = attribution();
622
+ const live = attr.byInitiative;
528
623
 
529
624
  /** One row's view model. `why` is null everywhere except in the working-set group. */
530
625
  const rowOf = (slug, why) => {
@@ -561,13 +656,26 @@ export function renderInitiatives(host) {
561
656
  *
562
657
  * Absent when empty. Not a "0", not a placeholder: a heading over nothing is a heading the PM
563
658
  * learns to skip. */
564
- const now = workingSet(live, product).filter((e) => !q || e.slug.includes(q));
565
- if (now.length) {
659
+ const now = workingSet(attr, product).filter((e) => !q || e.slug.includes(q));
660
+ /* EVERY LIVE SESSION PMOS CANNOT PLACE (workspace-session-attribution R5).
661
+ *
662
+ * The board shipped joining on the `initiative/<id>` branch alone, so a session on `main`, on a
663
+ * `claude/*` branch, or in a repo with no git simply vanished from it. Refusing to guess which
664
+ * initiative it belongs to is right; refusing to MENTION it is not, and only the first is honesty.
665
+ *
666
+ * It is rendered as a row inside the same group rather than as a group of its own, because it is
667
+ * not an initiative and must not sit in a list keyed by one. `+ bind` opens the chooser that
668
+ * turns it into the PM's own declaration. */
669
+ const orphans = q ? [] : (attr.unattributed || []);
670
+ if (now.length || orphans.length) {
671
+ const rows = now.map((e) => rowOf(e.slug, WS() ? WS().reasonTitle(e) : null));
566
672
  kids.push(groupNode({
567
- gid: 'NOW', dyn: true, idc: FX.C.brand, gname: 'working on', gct: String(now.length),
673
+ gid: 'NOW', dyn: true, idc: FX.C.brand, gname: 'working on',
674
+ gct: String(now.length + orphans.length),
568
675
  hasKr: true, krid: '⑂', krname: 'live sessions · checked-out trees · open tabs',
569
- inits: now.map((e) => rowOf(e.slug, WS() ? WS().reasonTitle(e) : null)),
676
+ inits: rows,
570
677
  isEmpty: false,
678
+ after: orphans.length ? orphanRows(orphans) : null,
571
679
  }, product));
572
680
  }
573
681
  for (const g of groups) kids.push(groupNode(g, product));
@@ -50,6 +50,11 @@ function initial() {
50
50
  * second click ends it, and anything else disarms. Neither is persisted: a chooser that
51
51
  * reappeared on reload would be a question about a moment that has passed. */
52
52
  startPrompt: null, endConfirm: null,
53
+ /* The bind screen's three transient bits (workspace-session-attribution R3): which live session
54
+ * is selected, what the PM has typed to find an initiative, and the server's own answer to the
55
+ * last bind. None is persisted — a half-finished declaration restored on reload would be a
56
+ * question about a moment that has passed, and `bindResult` is a report about one request. */
57
+ bindSess: null, bindTo: '', bindResult: null,
53
58
  col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
54
59
  flowsMap: null, schedList: null, agentList: null, memory: null,
55
60
  savedDocs: {}, scratchCopied: false,
@@ -360,7 +360,19 @@ function bind(rec) {
360
360
  SOCK.spawning.set(rec.tab.id, true);
361
361
  SOCK.spawnQueue.push(rec.tab.id);
362
362
  note(rec, 'pmos: starting a session…', true);
363
- const ok = send({ t: 'new', name: rec.tab.sess, agent: 'shell', ws: wsForTab(rec.tab) });
363
+ /* `initiative` is the ONE field this message was missing (workspace-session-attribution R2).
364
+ * `+ session` is clicked on an initiative row, `openTerm` puts that row's id on the tab as
365
+ * `slug`, and `state.js` persists it — so the answer to "what is this session for?" has been
366
+ * sitting on `rec.tab` at the moment of every spawn, while the message named three other fields.
367
+ * Without it the server could only infer the initiative from the git branch, which is why a
368
+ * session on `main` or on `claude/*` was invisible to the board.
369
+ *
370
+ * A tab with no slug sends nothing extra and the session is unattributed. That is the honest
371
+ * outcome, not a failure: the server never refuses a spawn over a label. */
372
+ const ok = send({
373
+ t: 'new', name: rec.tab.sess, agent: 'shell', ws: wsForTab(rec.tab),
374
+ initiative: rec.tab.slug || null,
375
+ });
364
376
  if (!ok) {
365
377
  SOCK.spawning.delete(rec.tab.id);
366
378
  SOCK.spawnQueue = SOCK.spawnQueue.filter((x) => x !== rec.tab.id);
@@ -1476,6 +1476,14 @@ function persist(s) {
1476
1476
  // fact: a record persisted before profiles existed restores as "not recorded", and inventing a
1477
1477
  // key for the past would be a lie about it.
1478
1478
  if ('profile' in s) meta.profile = s.profile == null ? null : s.profile;
1479
+ // WHAT THIS SESSION IS FOR, and where that came from. Same `in`-guard as `profile` above and for
1480
+ // the same reason: every session running before this field existed has no attribution, and
1481
+ // writing a key for it would turn "we were never told" into "it belongs to nothing". Both keys
1482
+ // move together — an attribution with no provenance could not be told apart from a reading.
1483
+ if ('initiative' in s) {
1484
+ meta.initiative = s.initiative == null ? null : s.initiative;
1485
+ meta.attributedBy = s.attributedBy || null;
1486
+ }
1479
1487
  try {
1480
1488
  fs.writeFileSync(path.join(SESS_DIR, s.id + '.json'), JSON.stringify(meta));
1481
1489
  fs.writeFileSync(path.join(SESS_DIR, s.id + '.scrollback'), s.buf);
@@ -1773,6 +1781,16 @@ function sessionList() {
1773
1781
  ? treeShare.roleOf(s.id, treeOfWs.get(s.ws) || null, writerByTree.get(treeOfWs.get(s.ws)))
1774
1782
  : null,
1775
1783
  tree_peers: treeShare.coTenants(rowsAll, treeOfWs, treeOfWs.get(s.ws) || null, s.id).length,
1784
+ // ── what this session is for (workspace-session-attribution R1) ──
1785
+ // THREE STATES, kept apart on the wire because the surface has to tell them apart:
1786
+ // initiative: 'x', attributed_by: 'spawn' PMOS was told at spawn — first-hand
1787
+ // initiative: 'x', attributed_by: 'declared' the PM bound a running session — their word
1788
+ // initiative: null, attributed_by: null never recorded — NOT "belongs to nothing"
1789
+ // The branch reading is deliberately NOT folded in here. It is the client's fallback
1790
+ // (lib/working-set.js), computed from `/api/workspaces`, and merging it into this field would
1791
+ // make an inference indistinguishable from a record — the exact confusion this field removes.
1792
+ initiative: 'initiative' in s ? (s.initiative == null ? null : s.initiative) : null,
1793
+ attributed_by: 'initiative' in s ? (s.attributedBy || null) : null,
1776
1794
  profile_id: 'profile' in s ? (s.profile == null ? null : s.profile) : null,
1777
1795
  profile_line: profilesLib.profileLine(
1778
1796
  !('profile' in s) ? undefined
@@ -2133,6 +2151,24 @@ function nextSessionId() {
2133
2151
  return id;
2134
2152
  }
2135
2153
 
2154
+ /**
2155
+ * An initiative id, or null — the ONE gate every attribution path goes through.
2156
+ *
2157
+ * `fourPanel.validId` is the repo's existing answer to "is this a record name?" and it is reused
2158
+ * rather than re-typed: it already guards the `/api/initiative` path interpolation and the two
2159
+ * declaration stores, so a second regex here would be a second opinion about what an id is, free to
2160
+ * drift from the one that decides whether a file can be read.
2161
+ *
2162
+ * Returning null rather than throwing is deliberate. An unusable value must leave the session
2163
+ * UNATTRIBUTED — which is a real, honest state with a surface of its own — never half-attributed to
2164
+ * something that failed to parse, and never a spawn refused over a label.
2165
+ */
2166
+ function initiativeIdOrNull(v) {
2167
+ if (v == null) return null;
2168
+ const s = String(v).trim();
2169
+ return s && fourPanel.validId(s) ? s : null;
2170
+ }
2171
+
2136
2172
  // Resolve a requested profile id BEFORE anything is spawned (profiles R3/R9). The rule the whole
2137
2173
  // feature turns on: a session must never claim a profile whose overlay was not applied. So the
2138
2174
  // three outcomes are kept apart — nothing asked for, asked for and readable, asked for and gone —
@@ -2150,7 +2186,7 @@ function resolveProfileForSpawn(id) {
2150
2186
  : `profile "${id}" is not among the descriptors in ${read.dir} — PMOS will not substitute another` };
2151
2187
  }
2152
2188
 
2153
- function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
2189
+ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative }) {
2154
2190
  const id = nextSessionId();
2155
2191
  const w = getWs(ws);
2156
2192
  const root = w ? w.root : SELF_ROOT;
@@ -2172,6 +2208,24 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
2172
2208
  const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag,
2173
2209
  profile: pres.id, profileRefusal: pres.ok ? null : pres.error };
2174
2210
 
2211
+ /* WHAT THIS SESSION IS FOR (workspace-session-attribution R1).
2212
+ *
2213
+ * A PMOS session has never recorded which initiative it is working on — not here, not on the wire,
2214
+ * not on disk — so every initiative-keyed surface had to INFER it from the `initiative/<id>` branch
2215
+ * convention, which holds in one of four ordinary situations. The board that shipped on that
2216
+ * inference could not see the session that wrote it, because that session was on a `claude/*`
2217
+ * branch.
2218
+ *
2219
+ * The key is set ONLY when the caller supplied one, exactly as `profile` is, and for the same
2220
+ * reason: its ABSENCE has to stay a fact. A session started before this field existed, or started
2221
+ * outside PMOS, restores as not-recorded — and "not recorded" must never be confused with
2222
+ * "recorded as none", which is what a `null` default would have produced. `attributedBy` says
2223
+ * where the value came from, because a value the PM declared and a value PMOS was told at spawn
2224
+ * are different claims and the surface has to be able to tell them apart.
2225
+ */
2226
+ const init = initiativeIdOrNull(initiative);
2227
+ if (init) { base.initiative = init; base.attributedBy = 'spawn'; }
2228
+
2175
2229
  // A missing agent CLI is detected BEFORE any spawn (on Linux the exec-in-fork failure is
2176
2230
  // otherwise silent) — the same honest exited pane on both the tmux and the direct path.
2177
2231
  if (!isShell && !commandExists(agent)) {
@@ -2247,7 +2301,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
2247
2301
  // A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
2248
2302
  // hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
2249
2303
  // git-then-maybe-spawn contract is unit-testable without a WS client.
2250
- function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile }) {
2304
+ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative }) {
2251
2305
  const w = getWs(ws);
2252
2306
  if (!w) return { ok: false, error: 'no workspace' };
2253
2307
  // The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
@@ -2289,7 +2343,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
2289
2343
  broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
2290
2344
  }
2291
2345
  }
2292
- const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id });
2346
+ const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative });
2293
2347
  // The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
2294
2348
  // block above. The isolate default already computes that other sessions would be disturbed — and
2295
2349
  // then the whole git block is skipped when no branch was picked, which is the common path and the
@@ -2377,6 +2431,10 @@ function startWork({ initiative, repoKey, ws, agent, name, profile }) {
2377
2431
  createBranch: plan.createBranch,
2378
2432
  isolate: plan.isolate,
2379
2433
  profile,
2434
+ // THE FIX THIS FUNCTION HAS BEEN ONE ARGUMENT AWAY FROM SINCE IT WAS WRITTEN. `initiative`
2435
+ // arrived here, decided the branch and supplied a fallback display name, and was then dropped
2436
+ // — so the one start path DESIGNED to know what it was starting work on never told the session.
2437
+ initiative,
2380
2438
  });
2381
2439
  if (!r.ok) return r;
2382
2440
  return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
@@ -3994,6 +4052,45 @@ async function handleRequest(req, res) {
3994
4052
  // Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
3995
4053
  return json(res, { ws: w ? w.id : null, ...out });
3996
4054
  }
4055
+ if (p === '/api/session/initiative' && req.method === 'POST') {
4056
+ /* Bind a RUNNING session to an initiative — the PM's own word about work already in flight
4057
+ * (workspace-session-attribution R3/R8).
4058
+ *
4059
+ * Every session running when this shipped has no attribution, and so does every session started
4060
+ * outside PMOS. Without this they would stay unattributed forever, which would make the whole
4061
+ * field a promise about the future and no help with the work on screen today.
4062
+ *
4063
+ * Three properties this endpoint holds:
4064
+ * 1. It is a DECLARATION. `attributedBy: 'declared'` is written, never 'spawn', so the surface
4065
+ * can keep the PM's word apart from what PMOS was told first-hand. Same distinction
4066
+ * `initiative-links.jsonl` already draws with `origin`/`state`.
4067
+ * 2. It is SINGLE-VALUED, and a replacement says what it replaced. `previous` comes back on
4068
+ * the response rather than being overwritten with no trace (R8) — a re-bind the PM did not
4069
+ * mean to do is otherwise unrecoverable and unnoticeable.
4070
+ * 3. It touches NOTHING else. No checkout, no branch, no respawn. The session keeps running
4071
+ * exactly as it was; the only thing that changes is what PMOS says it is for.
4072
+ *
4073
+ * `initiative: null` CLEARS the binding, which is the honest way back from a wrong one — and it
4074
+ * still writes the key, because a session the PM has explicitly unbound is a different state
4075
+ * from one that was never recorded.
4076
+ */
4077
+ let body = {};
4078
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
4079
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
4080
+ const s = sessions.get(String(body.id || ''));
4081
+ if (!s) return json(res, { ok: false, error: 'no such session' }, 404);
4082
+ const clearing = body.initiative === null || body.initiative === '';
4083
+ const next = clearing ? null : initiativeIdOrNull(body.initiative);
4084
+ if (!clearing && !next) {
4085
+ return json(res, { ok: false, error: 'that initiative id is not a record name' }, 400);
4086
+ }
4087
+ const previous = 'initiative' in s ? (s.initiative || null) : null;
4088
+ s.initiative = next;
4089
+ s.attributedBy = next ? 'declared' : null;
4090
+ persist(s);
4091
+ pushSessions();
4092
+ return json(res, { ok: true, id: s.id, initiative: next, attributed_by: s.attributedBy, previous });
4093
+ }
3997
4094
  if (p === '/api/initiative/link' && req.method === 'POST') {
3998
4095
  // Recording that an artifact corresponds to an external page. This writes a DECLARATION and
3999
4096
  // makes no request: PMOS holds no credential, so it can state what the PM says the mapping is
@@ -4522,7 +4619,10 @@ wss.on('connection', (ws) => {
4522
4619
  } else if (m.t === 'resize') {
4523
4620
  const s = sessions.get(m.id); if (s && s.pty) { try { s.pty.resize(m.cols, m.rows); s.cols = m.cols; s.rows = m.rows; } catch {} }
4524
4621
  } else if (m.t === 'new') {
4525
- const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate, profile: m.profile });
4622
+ // `initiative` is what the client has always held and never sent: `+ session` is clicked on
4623
+ // an initiative row, the tab carries its `slug`, and the spawn message named three other
4624
+ // fields. An absent or unusable value leaves the session unattributed — never a refused spawn.
4625
+ const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate, profile: m.profile, initiative: m.initiative });
4526
4626
  // A refused profile is its own sentence, not "branch: …" — the composer's error line names
4527
4627
  // what PMOS could not read, and nothing was started.
4528
4628
  if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'branch: ') + r.error })); return; }