create-pmos 0.24.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.
@@ -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));
@@ -37,6 +37,12 @@ function initial() {
37
37
  detail: { kind: 'init' },
38
38
  tabs: [], active: null, splitId: null,
39
39
  initLinks: {}, artLinks: {}, artSyncOpen: {}, artSyncState: {},
40
+ /* The sync half of panel 3, all per-window and none of it persisted. `artSyncTask` holds the
41
+ * task text the server composed for a direction the PM just chose; `linkGone` holds the
42
+ * references withdrawn since the last hydration, so a retracted row leaves the screen at the
43
+ * click rather than at the next read. Neither is a source of truth — what a link IS and what
44
+ * was synced both come back from the store on the next `/api/initiative`. */
45
+ artSyncTask: {}, linkGone: {},
40
46
  extraSess: {}, termHist: {}, startN: 1, docEdits: {}, docModes: {},
41
47
  /* `+ session` into a working tree that already has sessions in it opens a CHOOSER instead of
42
48
  * spawning (workspace-session-choice): `{slug, tree, branch, peers}` while it is open, null
@@ -44,6 +50,11 @@ function initial() {
44
50
  * second click ends it, and anything else disarms. Neither is persisted: a chooser that
45
51
  * reappeared on reload would be a question about a moment that has passed. */
46
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,
47
58
  col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
48
59
  flowsMap: null, schedList: null, agentList: null, memory: null,
49
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);
@@ -702,11 +702,21 @@ function readTextDoc(root, rel) {
702
702
  // falling back to the filename convention when an artifact declares nothing.
703
703
  // Ordered LONGEST-DISCRIMINATOR-FIRST: `-copy-brief` must be tested before `-brief`, and
704
704
  // `-eval-rubric` before any `-rubric`, because the first match wins and slices the stem.
705
+ //
706
+ // The qualified-brief entries (`-comms-brief`, `-launch-brief`) are here for a reason worth stating,
707
+ // because it is the same reason `-copy-brief` was: a stem ending `-comms-brief` ALSO ends `-brief`,
708
+ // so without its own entry it matches the shorter one and the slice leaves a `-comms` tail on the
709
+ // stem. That tail belongs to the qualifier, not to the initiative, and it is enough to put a comms
710
+ // brief in a different group from the PRD it was written for — which is precisely what the PM
711
+ // reported seeing (PRD, defect 3).
705
712
  const ROLE_SUFFIXES = [
706
713
  ['-eval-rubric', 'rubric'], ['-design-brief', 'design brief'], ['-design-reference', 'design'],
707
- ['-copy-brief', 'copy brief'], ['-user-stories', 'user stories'], ['-copy', 'copy'],
714
+ ['-copy-brief', 'copy brief'], ['-comms-brief', 'comms brief'], ['-launch-brief', 'launch brief'],
715
+ ['-user-stories', 'user stories'], ['-user-story', 'user stories'], ['-stories', 'user stories'],
716
+ ['-one-pager', 'one-pager'], ['-copy', 'copy'],
708
717
  ['-dogfood', 'dogfood'], ['-reconcile', 'reconcile'], ['-findings', 'findings'],
709
718
  ['-scoping', 'scoping'], ['-intake', 'intake'], ['-brief', 'brief'], ['-prd', 'PRD'],
719
+ ['-spec', 'spec'],
710
720
  ];
711
721
  const ROLE_BY_DIR = {
712
722
  prd: 'PRD', evals: 'eval', design: 'design', research: 'research', briefs: 'brief',
@@ -864,12 +874,34 @@ function initiativeIndex(root) {
864
874
  for (const m of metas) if (!m.initiative) byStem.set(m.stem, (byStem.get(m.stem) || 0) + 1);
865
875
  const groups = new Map();
866
876
  const ungrouped = [];
877
+ // PREFIX ATTACHMENT, and the three constraints that keep it from becoming a similarity score.
878
+ //
879
+ // The join above is stem EQUALITY, and equality is the wrong shape for how PMs name files. An
880
+ // initiative `ooh-zbsg-ast1` carries `ooh-zbsg-ast1-design-prd.md`, `ooh-zbsg-ast1-user-stories.md`
881
+ // and `ooh-zbsg-ast1-comms-brief.md`; after the role suffix is sliced those are three DIFFERENT
882
+ // stems, so the initiative's own PRD was attributed to no initiative and never reached panel 3.
883
+ // Measured on this repository one initiative ago: 148 documents attributed to nothing, 31 of them
884
+ // beginning with a declared id.
885
+ //
886
+ // 1. Only EXPLICITLY DECLARED ids may capture (`declared`, not `groups`). A group that exists
887
+ // only because two filenames happened to share a stem was never named by anyone, and giving
888
+ // it the authority to claim documents would let a naming coincidence annex a neighbourhood.
889
+ // 2. LONGEST WINS, so `workspace-session-choice-prd.md` cannot be captured by `workspace-session`
890
+ // when both are declared. Sorted once per call, not per document.
891
+ // 3. It runs LAST, after equality and after the convention join, so it can only move a document
892
+ // that would otherwise be `ungrouped`. It cannot take a document away from a group that has a
893
+ // better claim on it, which is what makes the total document count provably unchanged.
894
+ //
895
+ // The boundary being held here is the one `lib/related-docs.js` holds for the same reason: a
896
+ // concrete tie, never "feels related". `x-` is a tie the PM typed. Similarity is not.
897
+ const capturing = [...declared].sort((a, b) => b.length - a.length);
898
+ const prefixOwner = (stem) => capturing.find((id) => stem.length > id.length + 1 && stem.startsWith(id + '-')) || null;
867
899
  const keyFor = (m) => {
868
900
  if (m.initiative) return m.initiative;
869
901
  if (declared.has(m.stem)) return m.stem;
870
902
  // the convention join: `jira-sync-prd.md` + `jira-sync-eval-rubric.md` are one unit
871
903
  if ((byStem.get(m.stem) || 0) > 1) return m.stem;
872
- return null;
904
+ return prefixOwner(m.stem);
873
905
  };
874
906
  for (const m of metas) {
875
907
  const k = keyFor(m);
@@ -1444,6 +1476,14 @@ function persist(s) {
1444
1476
  // fact: a record persisted before profiles existed restores as "not recorded", and inventing a
1445
1477
  // key for the past would be a lie about it.
1446
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
+ }
1447
1487
  try {
1448
1488
  fs.writeFileSync(path.join(SESS_DIR, s.id + '.json'), JSON.stringify(meta));
1449
1489
  fs.writeFileSync(path.join(SESS_DIR, s.id + '.scrollback'), s.buf);
@@ -1741,6 +1781,16 @@ function sessionList() {
1741
1781
  ? treeShare.roleOf(s.id, treeOfWs.get(s.ws) || null, writerByTree.get(treeOfWs.get(s.ws)))
1742
1782
  : null,
1743
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,
1744
1794
  profile_id: 'profile' in s ? (s.profile == null ? null : s.profile) : null,
1745
1795
  profile_line: profilesLib.profileLine(
1746
1796
  !('profile' in s) ? undefined
@@ -2101,6 +2151,24 @@ function nextSessionId() {
2101
2151
  return id;
2102
2152
  }
2103
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
+
2104
2172
  // Resolve a requested profile id BEFORE anything is spawned (profiles R3/R9). The rule the whole
2105
2173
  // feature turns on: a session must never claim a profile whose overlay was not applied. So the
2106
2174
  // three outcomes are kept apart — nothing asked for, asked for and readable, asked for and gone —
@@ -2118,7 +2186,7 @@ function resolveProfileForSpawn(id) {
2118
2186
  : `profile "${id}" is not among the descriptors in ${read.dir} — PMOS will not substitute another` };
2119
2187
  }
2120
2188
 
2121
- function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
2189
+ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative }) {
2122
2190
  const id = nextSessionId();
2123
2191
  const w = getWs(ws);
2124
2192
  const root = w ? w.root : SELF_ROOT;
@@ -2140,6 +2208,24 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
2140
2208
  const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag,
2141
2209
  profile: pres.id, profileRefusal: pres.ok ? null : pres.error };
2142
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
+
2143
2229
  // A missing agent CLI is detected BEFORE any spawn (on Linux the exec-in-fork failure is
2144
2230
  // otherwise silent) — the same honest exited pane on both the tmux and the direct path.
2145
2231
  if (!isShell && !commandExists(agent)) {
@@ -2215,7 +2301,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
2215
2301
  // A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
2216
2302
  // hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
2217
2303
  // git-then-maybe-spawn contract is unit-testable without a WS client.
2218
- function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile }) {
2304
+ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative }) {
2219
2305
  const w = getWs(ws);
2220
2306
  if (!w) return { ok: false, error: 'no workspace' };
2221
2307
  // The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
@@ -2257,7 +2343,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
2257
2343
  broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
2258
2344
  }
2259
2345
  }
2260
- 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 });
2261
2347
  // The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
2262
2348
  // block above. The isolate default already computes that other sessions would be disturbed — and
2263
2349
  // then the whole git block is skipped when no branch was picked, which is the common path and the
@@ -2345,6 +2431,10 @@ function startWork({ initiative, repoKey, ws, agent, name, profile }) {
2345
2431
  createBranch: plan.createBranch,
2346
2432
  isolate: plan.isolate,
2347
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,
2348
2438
  });
2349
2439
  if (!r.ok) return r;
2350
2440
  return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
@@ -3952,6 +4042,7 @@ async function handleRequest(req, res) {
3952
4042
  : null,
3953
4043
  artifactLinks: fourPanel.readDeclarations(stateDir, fourPanel.ARTIFACT_LINKS),
3954
4044
  initiativeLinks: fourPanel.readDeclarations(stateDir, fourPanel.INITIATIVE_LINKS),
4045
+ artifactSync: fourPanel.readDeclarations(stateDir, fourPanel.ARTIFACT_SYNC),
3955
4046
  // Which destinations a session could look up, derived from the PM's agent profiles. The home
3956
4047
  // directory is passed IN rather than read inside four-panel.js for the same reason
3957
4048
  // readProfiles takes it as a parameter: a module that reads os.homedir() itself can only be
@@ -3961,6 +4052,45 @@ async function handleRequest(req, res) {
3961
4052
  // Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
3962
4053
  return json(res, { ws: w ? w.id : null, ...out });
3963
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
+ }
3964
4094
  if (p === '/api/initiative/link' && req.method === 'POST') {
3965
4095
  // Recording that an artifact corresponds to an external page. This writes a DECLARATION and
3966
4096
  // makes no request: PMOS holds no credential, so it can state what the PM says the mapping is
@@ -3986,6 +4116,36 @@ async function handleRequest(req, res) {
3986
4116
  // A write that failed returns 500 and says so — never a false green (the /api/gate rule).
3987
4117
  return json(res, { ...wrote, connectors: fourPanel.connectors(os.homedir()) }, wrote.ok ? 200 : 500);
3988
4118
  }
4119
+ if (p === '/api/initiative/sync' && req.method === 'POST') {
4120
+ // The PM chose a direction for a link they already recorded, or a session is reporting what it
4121
+ // did about one. Both arrive here, and the difference between them is exactly the difference the
4122
+ // store enforces: a direction the PM chose is `proposed` and claims nothing, while a session's
4123
+ // report is `applied` and is REFUSED unless it names the profile that performed it.
4124
+ //
4125
+ // This route composes the task text and appends the row. It performs no sync — there is no
4126
+ // branch here that contacts Confluence, Jira or Google, and there cannot be one: the workspace
4127
+ // holds no credential (D53), and `no-network.test.js` asserts the absence structurally.
4128
+ const w = getWs(url.searchParams.get('ws'));
4129
+ let body = {};
4130
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
4131
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
4132
+ const w2 = body.ws ? getWs(body.ws) : w;
4133
+ const built = fourPanel.syncRow(body);
4134
+ if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
4135
+ const task = fourPanel.syncTask({
4136
+ direction: built.row.direction, kind: built.row.kind,
4137
+ artifact_path: built.row.artifact_path, target: built.row.target, profile: body.profile || null,
4138
+ });
4139
+ const engine = consoleState.loadEngine();
4140
+ if (!engine.ok) return json(res, { ok: false, error: engine.error }, 500);
4141
+ const stateDir = fourPanel.storeDir(engine.engine, w2 ? w2.root : SELF_ROOT);
4142
+ const wrote = fourPanel.appendDeclaration(stateDir, fourPanel.ARTIFACT_SYNC, built.row);
4143
+ // A write that failed returns 500 and says so — never a false green (the /api/gate rule). The
4144
+ // task is returned either way: a PM whose store is unwritable can still run the work by hand,
4145
+ // and withholding the text would turn a recording failure into a capability failure.
4146
+ return json(res, { ...wrote, task: task.ok ? task.text : null, task_error: task.ok ? null : task.error },
4147
+ wrote.ok ? 200 : 500);
4148
+ }
3989
4149
  if (p === '/api/initiative/lookup-task' && req.method === 'POST') {
3990
4150
  // The task a SESSION runs to resolve a reference. This endpoint composes a string and returns
3991
4151
  // it. It contacts nothing — that is the entire point of the design: PMOS describes the lookup,
@@ -4459,7 +4619,10 @@ wss.on('connection', (ws) => {
4459
4619
  } else if (m.t === 'resize') {
4460
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 {} }
4461
4621
  } else if (m.t === 'new') {
4462
- 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 });
4463
4626
  // A refused profile is its own sentence, not "branch: …" — the composer's error line names
4464
4627
  // what PMOS could not read, and nothing was started.
4465
4628
  if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'branch: ') + r.error })); return; }