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.
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.24.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@ac2ee81
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.
@@ -57,6 +57,15 @@ const path = require('path');
57
57
 
58
58
  const ARTIFACT_LINKS = 'artifact-links.jsonl';
59
59
  const INITIATIVE_LINKS = 'initiative-links.jsonl';
60
+ /**
61
+ * The THIRD store, and the newest place this module has to refuse something.
62
+ *
63
+ * A link says *this file corresponds to that page*. A sync row says *the PM decided which way the
64
+ * content should travel, and here is what came back*. They are different facts with different
65
+ * lifetimes — a link outlives any number of syncs — so they are different files rather than a
66
+ * `direction` column bolted onto a link row, where every re-sync would supersede the link itself.
67
+ */
68
+ const ARTIFACT_SYNC = 'artifact-sync.jsonl';
60
69
 
61
70
  /** The destinations the surface can declare. A kind outside this set is refused, not stored. */
62
71
  const LINK_KINDS = ['confluence', 'jira', 'google'];
@@ -70,12 +79,38 @@ const LINK_KINDS = ['confluence', 'jira', 'google'];
70
79
  * resolved — a lookup performed BY A SESSION that holds the PM's MCP returned this record.
71
80
  * Carries the destination's own title and the moment it was read.
72
81
  * unreachable — a lookup was attempted and did not succeed. Carries the reason it failed.
82
+ * retracted — the PM withdrew this reference. It is a STATE and not an absence, because the
83
+ * store is append-only and "the PM removed this" is a fact worth keeping; a link
84
+ * deleted from disk would be a link that never existed, and the history of what the
85
+ * PM declared is the whole reason this file is a ledger. A later declaration for
86
+ * the same pair brings the reference back, because `linkView` reads the latest row
87
+ * and nothing here is a tombstone.
73
88
  *
74
89
  * `connected` was the word this surface used before, and it is retired rather than re-worded: PMOS
75
90
  * is never connected to anything — a session is — so the word had no honest referent here. It is
76
91
  * also the word that let a row the PM had merely declared read as a row PMOS had verified.
77
92
  */
78
- const LINK_STATES = ['declared', 'resolved', 'unreachable'];
93
+ const LINK_STATES = ['declared', 'resolved', 'unreachable', 'retracted'];
94
+
95
+ /**
96
+ * Which way the content travels, and what happened. The sync store's own closed vocabulary.
97
+ *
98
+ * push — the repo file is the source; its content goes to the destination.
99
+ * adopt — the destination is the source; its content comes into the repo file.
100
+ *
101
+ * proposed — the PM chose this direction. A task was composed. NOTHING has been written anywhere,
102
+ * and this is the only state PMOS itself can produce.
103
+ * applied — a session holding the PM's MCP performed the write and reported back. Carries the
104
+ * profile that did it and the moment it did.
105
+ * failed — the attempt was made and did not succeed. Carries the reason.
106
+ *
107
+ * There is deliberately no `in sync`. The prototype's status line read `pushed · repo → jira · in
108
+ * sync 12:41`, where `12:41` was a literal in a seed module and `in sync` was a claim about two
109
+ * documents PMOS had never compared. `applied` says the smaller true thing: a session wrote, and
110
+ * here is who and when.
111
+ */
112
+ const SYNC_DIRECTIONS = ['push', 'adopt'];
113
+ const SYNC_STATES = ['proposed', 'applied', 'failed'];
79
114
 
80
115
  /**
81
116
  * How a reference came to be in the store. This is the field that makes the store cleanable: the
@@ -384,6 +419,12 @@ function provenance(body, at) {
384
419
  if (state === 'unreachable' && !reason) {
385
420
  return { error: 'an unreachable reference must carry the reason the lookup failed' };
386
421
  }
422
+ // Withdrawing a reference is the PM's act and nobody else's. A session that could retract could
423
+ // silently un-say something the PM said, which is the mirror image of the rule above — there, a
424
+ // caller may not claim to have read what it did not read; here, it may not claim to be the person.
425
+ if (state === 'retracted' && origin !== 'typed') {
426
+ return { error: 'a retraction is the PM withdrawing their own reference — send origin: typed' };
427
+ }
387
428
  return {
388
429
  origin,
389
430
  state,
@@ -472,7 +513,7 @@ function initiativeLinkRow(body, { at } = {}) {
472
513
  * the other. Rows are ordered by their recorded time with file order as the tiebreak, because two
473
514
  * rows can share a timestamp and file order is the only thing that then says which was written second.
474
515
  */
475
- function linkView(rows) {
516
+ function linkView(rows, { includeRetracted = false } = {}) {
476
517
  const list = Array.isArray(rows) ? rows : [];
477
518
  const seen = new Map();
478
519
  list.forEach((row, i) => {
@@ -493,7 +534,154 @@ function linkView(rows) {
493
534
  // this initiative was opened to make identifiable. They are reported as what they are.
494
535
  origin: row.origin || null,
495
536
  state: row.state || (row.origin ? 'declared' : 'unattributed'),
496
- }));
537
+ }))
538
+ // A pair whose LATEST row is a retraction is not a link any more, and the surface is a statement
539
+ // of now. The row it came from is still on disk and still readable — `includeRetracted` is how
540
+ // a caller that wants the history rather than the statement asks for it — but the panel that
541
+ // asks "what is linked" gets an answer that does not include what the PM withdrew.
542
+ .filter((row) => includeRetracted || row.state !== 'retracted');
543
+ }
544
+
545
+ /* ── the sync ledger ────────────────────────────────────────────────────────────────────────── */
546
+
547
+ /**
548
+ * Validate + normalise one sync row, and enforce the rule this whole store exists for.
549
+ *
550
+ * THE RULE, which is `provenance`'s rule carried onto a stronger verb: **`applied` cannot be
551
+ * asserted, only evidenced.** A row may claim a session wrote to the destination only if it can name
552
+ * the profile whose session did it. A caller that says "applied" and cannot say by whom is not
553
+ * describing a write.
554
+ *
555
+ * It matters more here than it did for `resolved`, not less. A failed search is obviously
556
+ * unfinished; a *publish* that a PM ran in another window looks finished from inside this process,
557
+ * and the difference between "the task was composed" and "the page was updated" is invisible from
558
+ * here. `proposed` is therefore the ONLY state PMOS can produce on its own, and it says exactly what
559
+ * happened: a direction was chosen and a task was written down.
560
+ */
561
+ function syncRow(body, { at } = {}) {
562
+ const artifact_path = String((body && body.artifact_path) || '').trim();
563
+ const kind = String((body && body.kind) || '').trim().toLowerCase();
564
+ const target = String((body && body.target) || '').trim();
565
+ const direction = String((body && body.direction) || '').trim().toLowerCase();
566
+ if (!artifact_path) return { ok: false, error: 'artifact_path is required' };
567
+ if (artifact_path.includes('..')) return { ok: false, error: 'artifact_path must be a repo-relative path' };
568
+ if (!LINK_KINDS.includes(kind)) return { ok: false, error: `kind must be one of: ${LINK_KINDS.join(', ')}` };
569
+ if (!target) return { ok: false, error: 'target is required — a sync needs the destination it was linked to' };
570
+ if (!SYNC_DIRECTIONS.includes(direction)) {
571
+ return { ok: false, error: `direction must be one of: ${SYNC_DIRECTIONS.join(', ')}` };
572
+ }
573
+ const state = String((body && body.state) || 'proposed').trim().toLowerCase();
574
+ if (!SYNC_STATES.includes(state)) return { ok: false, error: `state must be one of: ${SYNC_STATES.join(', ')}` };
575
+ const profile = body && body.profile ? String(body.profile).slice(0, 120) : null;
576
+ const reason = body && body.reason ? String(body.reason).slice(0, 300) : null;
577
+ const detail = body && body.detail ? String(body.detail).slice(0, 300) : null;
578
+ if (state === 'applied' && !profile) {
579
+ return { ok: false, error: 'an applied sync must name the profile whose session performed it — PMOS wrote nothing itself. Send profile with the session that did.' };
580
+ }
581
+ if (state === 'failed' && !reason) {
582
+ return { ok: false, error: 'a failed sync must carry the reason it failed' };
583
+ }
584
+ const initiative_id = body && body.initiative_id ? String(body.initiative_id) : null;
585
+ if (initiative_id && !validId(initiative_id)) return { ok: false, error: 'that initiative id is not a record name' };
586
+ return {
587
+ ok: true,
588
+ row: {
589
+ at: at || new Date().toISOString(),
590
+ initiative_id,
591
+ artifact_path,
592
+ kind,
593
+ target: target.slice(0, 300),
594
+ direction,
595
+ state,
596
+ profile,
597
+ reason,
598
+ // What the session reported about what it wrote — a page version, a commit, a summary of the
599
+ // change. Free text, and only ever the session's own words: nothing here composes one.
600
+ detail,
601
+ // Only a row that actually wrote something carries a write time, for the same reason only a
602
+ // row that read something carries a read time. Absent means nobody has written, which is a
603
+ // fact the surface renders rather than a gap it fills in with a clock.
604
+ applied_at: state === 'applied' ? (at || new Date().toISOString()) : null,
605
+ },
606
+ };
607
+ }
608
+
609
+ /**
610
+ * One row per (artifact, destination kind): the latest sync, which is what the status line says.
611
+ * Same read-time collapse as `linkView` and for the same reason — the store is the history, the
612
+ * surface is the statement of now.
613
+ */
614
+ function syncView(rows) {
615
+ const list = Array.isArray(rows) ? rows : [];
616
+ const seen = new Map();
617
+ list.forEach((row, i) => {
618
+ if (!row || typeof row !== 'object') return;
619
+ const key = String(row.artifact_path || '') + '|' + String(row.kind || '');
620
+ const prev = seen.get(key);
621
+ if (!prev) { seen.set(key, { row, i }); return; }
622
+ const a = String(prev.row.at || ''), b = String(row.at || '');
623
+ if (b > a || (b === a && i > prev.i)) seen.set(key, { row, i });
624
+ });
625
+ return [...seen.values()].sort((x, y) => x.i - y.i).map(({ row }) => ({ ...row }));
626
+ }
627
+
628
+ /**
629
+ * The task a session performs to carry out a sync. Composed here, on the server, for the same
630
+ * reason `lookupTask` is: it is the thing the design turns on, and a string built in a click handler
631
+ * is a string no test ever sees.
632
+ *
633
+ * Note what changed from `lookupTask`, because it is the whole risk of this initiative. That task
634
+ * said *search, and do not create, publish or modify anything*. This one asks a session to WRITE.
635
+ * So it names its target and names what it will overwrite, in both directions, and forbids touching
636
+ * anything else — the cost of a vague target here is an overwritten Confluence page or an
637
+ * overwritten repo file, not a wrong pixel. It also asks the session to record the outcome through
638
+ * the same validated endpoint, where a claim of success will be refused unless it names the profile
639
+ * that made it.
640
+ */
641
+ function syncTask({ direction, kind, artifact_path, target, profile } = {}) {
642
+ const d = String(direction || '').toLowerCase();
643
+ const k = String(kind || '').toLowerCase();
644
+ if (!SYNC_DIRECTIONS.includes(d)) return { ok: false, error: `direction must be one of: ${SYNC_DIRECTIONS.join(', ')}` };
645
+ if (!LINK_KINDS.includes(k)) return { ok: false, error: `kind must be one of: ${LINK_KINDS.join(', ')}` };
646
+ const file = String(artifact_path || '').slice(0, 300);
647
+ const dest = String(target || '').slice(0, 300);
648
+ if (!file) return { ok: false, error: 'artifact_path is required' };
649
+ if (!dest) return { ok: false, error: 'target is required' };
650
+ const what = k === 'jira' ? 'Jira issue' : k === 'confluence' ? 'Confluence page' : 'Google Doc';
651
+ const lines = d === 'push'
652
+ ? [
653
+ `Publish the repository file ${file} to the ${what} at ${dest}.`,
654
+ '',
655
+ `Use your ${k} MCP tools. THE DESTINATION YOU WILL OVERWRITE IS ${dest} — write to that record`,
656
+ 'and to nothing else. Do not create a new page, do not touch a parent or a child, and do not',
657
+ 'edit any other record you happen to find while looking.',
658
+ 'Convert the markdown to the destination\'s own format; keep the repository file unchanged.',
659
+ 'Report the version or revision you wrote and the time you wrote it.',
660
+ ]
661
+ : [
662
+ `Bring the ${what} at ${dest} into the repository file ${file}.`,
663
+ '',
664
+ `Use your ${k} MCP tools to READ ${dest}. THE FILE YOU WILL OVERWRITE IS ${file} — write to`,
665
+ 'that path and to nothing else, and PRESERVE ITS EXISTING YAML FRONTMATTER BLOCK VERBATIM:',
666
+ 'the repository\'s gates read those keys, and a document that arrives without them fails CI.',
667
+ 'Convert the destination\'s content to markdown, and report what changed in the file.',
668
+ ];
669
+ return {
670
+ ok: true,
671
+ direction: d,
672
+ profile: profile || null,
673
+ text: [
674
+ ...lines,
675
+ '',
676
+ 'Then record the outcome: POST /api/initiative/sync with state applied and the profile you ran',
677
+ `under (${profile || 'the profile holding this MCP'}), or state failed with the reason verbatim.`,
678
+ 'A success that cannot name the profile that performed it will be refused, and refusal is',
679
+ 'correct — PMOS wrote nothing itself and must not report that it did.',
680
+ 'If you did not complete the write, say so plainly. An unreported failure reads here as a',
681
+ 'sync that never happened, which is the honest outcome; a reported success that did not',
682
+ 'happen is the defect this whole surface exists to refuse.',
683
+ ].join('\n'),
684
+ };
497
685
  }
498
686
 
499
687
  /**
@@ -537,7 +725,7 @@ function lookupTask({ kind, subject, hint, profile } = {}) {
537
725
  * artifacts are still listed, because the library can see them and claiming otherwise would hide
538
726
  * real files), `{ok:true,…}` otherwise.
539
727
  */
540
- function assemble({ id, rec, ladder, links, index, sessions, workspaces, anchor, artifactLinks, initiativeLinks, homeDir }) {
728
+ function assemble({ id, rec, ladder, links, index, sessions, workspaces, anchor, artifactLinks, initiativeLinks, artifactSync, homeDir }) {
541
729
  if (!rec.ok) return { ok: false, id, error: rec.error };
542
730
  const artifacts = artifactsFor(index, id);
543
731
  const sess = sessionsFor({ sessions, workspaces, id });
@@ -565,16 +753,23 @@ function assemble({ id, rec, ladder, links, index, sessions, workspaces, anchor,
565
753
  artifacts: mine(artifactLinks, (r) => r && (r.initiative_id === id
566
754
  || artifacts.some((a) => a.path === r.artifact_path))),
567
755
  links: mine(initiativeLinks, (r) => r && r.initiative_id === id),
756
+ // Scoped the same way the links are, and collapsed the same way: this initiative's rows only,
757
+ // latest per (artifact, destination). A sync row for another initiative's copy of the same
758
+ // path is not this initiative's status line.
759
+ sync: syncView((artifactSync && artifactSync.ok ? artifactSync.rows : []).filter((r) => r
760
+ && (r.initiative_id === id || artifacts.some((a) => a.path === r.artifact_path)))),
568
761
  artifacts_error: artifactLinks && artifactLinks.ok ? null : (artifactLinks && artifactLinks.error) || null,
569
762
  links_error: initiativeLinks && initiativeLinks.ok ? null : (initiativeLinks && initiativeLinks.error) || null,
763
+ sync_error: artifactSync && artifactSync.ok ? null : (artifactSync && artifactSync.error) || null,
570
764
  },
571
765
  connectors: connectors(homeDir),
572
766
  };
573
767
  }
574
768
 
575
769
  module.exports = {
576
- ARTIFACT_LINKS, INITIATIVE_LINKS, LINK_KINDS, LINK_STATES, LINK_ORIGINS, MCP_FOR_KIND,
770
+ ARTIFACT_LINKS, INITIATIVE_LINKS, ARTIFACT_SYNC,
771
+ LINK_KINDS, LINK_STATES, LINK_ORIGINS, MCP_FOR_KIND, SYNC_DIRECTIONS, SYNC_STATES,
577
772
  validId, connectors, readRecord, parseTasks, artifactsFor, sessionsFor, anchorFor,
578
773
  storeDir, readDeclarations, appendDeclaration, artifactLinkRow, initiativeLinkRow, assemble,
579
- linkView, lookupTask,
774
+ linkView, lookupTask, syncRow, syncView, syncTask,
580
775
  };
@@ -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.24.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.24.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.24.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",