create-pmos 0.18.0 → 0.19.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,63 @@ 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.19.0
337
+
338
+ **A long document scrolls its own panel, and starting a session asks what is already running.** Run
339
+ `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
340
+
341
+ - **Opening a long document no longer scrolls the whole window.** Scrolling a document in the right
342
+ panel moved the entire interface — the top bar slid away, the status bar rose from below — while
343
+ the panel's own scrollbar never engaged. Every panel now scrolls inside itself and the app frame
344
+ never moves. (If you are curious: the shell's middle row was written `1fr`, which is
345
+ `minmax(auto, 1fr)`, so a tall document enlarged the row rather than overflowing it. Every style
346
+ involved read as correct, which is why it survived review.)
347
+ - **`+ session` now asks before it starts one, when the checkout is already busy.** It used to
348
+ start the session and *then* tell you how many others were in there — a count arriving after the
349
+ decision, naming something you cannot act on. Now you get a chooser first, listing every session
350
+ in that checkout.
351
+ - **It says what each one is.** Its name, its agent, the same word the sidebar uses (`working` ·
352
+ `quiet 12m` · `needs you` · `maybe blocked`), the agent's own last words, and who holds the writer
353
+ claim.
354
+ - **…and what ending it would cost, without over-claiming.** Only `needs you` reads as settled —
355
+ its turn finished and it is waiting on you. `working` says output is arriving right now. `quiet`
356
+ and `maybe blocked` say, in as many words, that the workspace *cannot tell* a finished turn from a
357
+ permission prompt from a stuck tool. A confident "safe to end" that discarded a running turn would
358
+ be worse than no reading at all.
359
+ - **You can take the running session instead of making another.** `open it` attaches a tab to it and
360
+ starts nothing. `end it` is there too — the first click arms it and names what it will end, the
361
+ second does it. `+ start another session here anyway` is always one click: being told is never
362
+ being blocked, and no session is ever refused because another is there.
363
+ - **The same detail is written into the new session's scrollback**, so scrolling back tomorrow
364
+ answers the same question the chooser did.
365
+
366
+ ## What's new in 0.18.1
367
+
368
+ **Three fixes to the workspace terminal, all of them about what the window knew about a session.**
369
+ Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
370
+
371
+ - **Your terminal now uses its whole width.** Nothing ever told the shell how wide the browser
372
+ was. A session starts at 80 columns; the server stated its own size on attach rather than
373
+ adopting yours, and the browser only spoke up when a resize *changed* its grid — which happened
374
+ once, while the connection was still opening and there was no session to tell. So the process on
375
+ the other end kept wrapping at 80 columns inside a window three times that wide, and a
376
+ full-screen agent CLI painted into a box that was not there.
377
+ - **The window stops rebuilding itself while you type.** It repainted every panel every four
378
+ seconds whether or not anything had changed — on a project with a hundred-odd initiatives that is
379
+ a real stall, on a timer, next to a terminal you are typing into. It now repaints when something
380
+ it shows actually moves. Separately, the server rewrote every session's scrollback file to disk
381
+ every five seconds, synchronously, whether or not a byte had changed; it now writes only what
382
+ moved.
383
+ - **The scratchpad belongs to a session again.** It had become one shared buffer for the whole
384
+ page, so every terminal — in every repository — showed and overwrote the same text, and none of
385
+ it outlived the tab. Each session has its own pad again, saved on your machine, and it comes back
386
+ with the session.
387
+ - **Reopening the workspace brings your sessions back.** Open tabs are remembered and re-attach to
388
+ the exact session they were on, named on the tab so you can tell which one it is. Previously they
389
+ were forgotten on reload and had to be re-found by hand — and the re-finding matched on a session
390
+ *name*, which is not unique between repositories, so a tab in one project could silently attach
391
+ to a shell running in another. `+ session` also always starts a new session now, instead of
392
+ sometimes adopting one that was already running.
336
393
  ## What's new in 0.18.0
337
394
 
338
395
  **Several sessions in one folder now say so.** Run `npx create-pmos@latest workspace`; the version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. `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@83f52ff
1
+ kernel-source: pmos@181425a
2
2
  assembled: 2026-07-31
3
3
  mode: vendor
@@ -49,11 +49,18 @@ def review_gate_pass(verdict, score):
49
49
 
50
50
  def leading_frontmatter(path):
51
51
  """The LEADING frontmatter block only — body text cannot fake a declaration. Mirror of
52
- workspace/lib/file-mode-parse.js parseLeadingFrontmatter (agreement enforced by the fixture test)."""
52
+ workspace/lib/file-mode-parse.js parseLeadingFrontmatter (agreement enforced by the fixture test).
53
+
54
+ The value group is `(.*?)`, NOT `(.+?)`. A key written bare (`prd_path:` with nothing after it)
55
+ is legal YAML for a null value; under `.+?` the line does not match, so it reads as the first
56
+ body line, the loop breaks, and every key below it is dropped. Agreement with the JS parser was
57
+ never sufficient to catch this — both carried the same wrong regex, so the fixture compared two
58
+ identical mistakes and passed. See kit/fixtures/state/initiatives/epsilon-empty-value.md.
59
+ """
53
60
  fm = {}
54
61
  with open(path, encoding="utf-8") as fh:
55
62
  for line in fh:
56
- m = re.match(r"^([a-z_]+):\s*(.+?)\s*$", line.rstrip("\n"))
63
+ m = re.match(r"^([a-z_]+):\s*(.*?)\s*$", line.rstrip("\n"))
57
64
  if m:
58
65
  fm.setdefault(m.group(1), m.group(2))
59
66
  elif line.strip() and not line.startswith("---"):
@@ -162,6 +162,24 @@ session in a tree that already has live ones now says so in the pane — naming
162
162
  way out (⑂ a new branch in its own folder). It fires on the plain `+ session` path too, where no
163
163
  git operation runs at all: the server already knew, and used to say nothing.
164
164
 
165
+ **`+ session` asks before it starts one.** A count arriving *after* the spawn is a fact you cannot
166
+ act on, and the answer you usually want is not "start another" at all — it is "which of those is the
167
+ one I was already working in?". So clicking `+ session` into an occupied tree opens a chooser
168
+ first, listing every session in that tree with:
169
+
170
+ - **what it is** — its name, its agent, the sidebar's own word for its state (`working` ·
171
+ `quiet 12m` · `needs you` · `maybe blocked`), the agent's last words, and the writer claim;
172
+ - **what ending it would cost** — one sentence per session, worded from the evidence. Only
173
+ `needs you` reads as settled ("its turn finished and it is waiting on you"). `quiet` and
174
+ `maybe blocked` say, in as many words, that PMOS *cannot tell* — a confident "safe" that
175
+ discards a running turn is worse than no reading at all;
176
+ - **`open it`** — attach a tab to that session and start nothing.
177
+
178
+ `end it` is there too, and is the only destructive control on the surface: two clicks, the first
179
+ arming it. `+ start another session here anyway` is always one click away — informing you is never
180
+ obstructing you. The same per-session detail is written into the new pane's scrollback, so scrolling
181
+ back tomorrow answers the same question the chooser did.
182
+
165
183
  **The tree is the unit, not the folder you registered.** A repo registered twice — say `~/repo` and
166
184
  `~/repo/packages/app`, the ordinary shape for a product repo added by path — is **one** tree, and
167
185
  its sessions are told about each other. Two *worktrees* of one repo are **two** trees, and are not
@@ -178,7 +196,9 @@ Be clear about what this buys: **PMOS hosts your agent's terminal, it does not s
178
196
  the files.** Nothing here prevents a write, a checkout or a commit, and no spawn is ever refused
179
197
  because someone else is in the tree. It makes a shared tree visible; the discipline is still yours.
180
198
  (The role rides on `/api/sessions` rows as `tree` / `tree_role` / `tree_peers`; the tab-bar chip is
181
- not wired yet.)
199
+ not wired yet. `/api/workspaces` publishes each registration's `tree`, which is what lets the
200
+ chooser answer "who is already in there?" *before* anything is started; `/api/tree/peers` returns
201
+ each session described — `shown`, `label`, `doing` and a `risk` — rather than a list of ids.)
182
202
 
183
203
  A workspace anywhere **inside** a repo knows its branch — a subdirectory (`repo/packages/app`)
184
204
  counts, which matters for a product repo added by path. A folder with **no git** simply says so and
@@ -14,10 +14,21 @@ const reviewGate = require('./review-gate');
14
14
  // The LEADING YAML-ish frontmatter block ONLY — body text must never be able to fake a declaration.
15
15
  // Mirrors metrics.py exactly: `key: value` lines (first value wins), `---` fences and blank lines are
16
16
  // skipped, and the FIRST non-frontmatter, non-fence, non-blank line ends the block.
17
+ //
18
+ // The value group is `(.*?)`, NOT `(.+?)`. A key written bare — `prd_path:` with nothing after it —
19
+ // is legal YAML for a null value, and 30 of the 136 records in planning/initiatives/ are written
20
+ // that way. Under `.+?` such a line does not match, so it is treated as the first body line, the
21
+ // loop BREAKS, and every key below it is silently dropped: parent_kr, outcome_metric, the lot. The
22
+ // spine then rendered those initiatives as "unanchored — rejected work under D59".
23
+ //
24
+ // This is not a hypothetical. metrics.py carried the identical regex and kit/fixtures/test_parsers.py
25
+ // existed to prove the two agreed — which they did, on being wrong, for as long as the fixture only
26
+ // ever compared them to each other. scripts/workflow-state.js:105 was the one correct parser and was
27
+ // compared against nothing. The fixture now asserts VALUES; see epsilon-empty-value.md.
17
28
  function parseLeadingFrontmatter(text) {
18
29
  const fm = {};
19
30
  for (const line of String(text == null ? '' : text).split('\n')) {
20
- const m = line.match(/^([a-z_]+):\s*(.+?)\s*$/);
31
+ const m = line.match(/^([a-z_]+):\s*(.*?)\s*$/);
21
32
  if (m) { if (!(m[1] in fm)) fm[m[1]] = m[2]; continue; }
22
33
  if (line.trim() && !line.startsWith('---')) break;
23
34
  }
@@ -0,0 +1,118 @@
1
+ /* workspace-repo-first · the start-work policy (PRD R1 · R3)
2
+ * ──────────────────────────────────────────────────────────────────────────────────────────────
3
+ * ONE declared rule for where a session starts, replacing a decision the server used to make per
4
+ * call from invisible state.
5
+ *
6
+ * What it replaced, and why that had to go. `spawnWithBranch` chose isolation with
7
+ *
8
+ * if (isolate === undefined || isolate === null) isolate = liveSessionCount(w.id) > 0;
9
+ *
10
+ * — so the SAME click created a worktree or moved the shared checkout depending on whether some
11
+ * other session happened to be alive at that moment. A PM cannot hold that rule in their head,
12
+ * cannot predict it, and cannot see it in the UI before pressing the button. It is not a policy;
13
+ * it is a coin flip with a plausible-looking reason.
14
+ *
15
+ * The rule this module states instead:
16
+ *
17
+ * no initiative chosen → the repository's MAIN working tree, on whatever branch it is on.
18
+ * No git operation at all. Nothing moves, no folder appears.
19
+ * an initiative chosen → that initiative's own worktree on `initiative/<id>`, created if absent
20
+ * and REUSED if present. The main tree is never moved off its branch.
21
+ *
22
+ * The PM's framing, which this encodes: keep the main checkout on main, and let work live in
23
+ * worktrees rather than accumulating branches in the shared tree.
24
+ *
25
+ * Two consequences worth stating because they are easy to get wrong:
26
+ *
27
+ * * If the MAIN tree already happens to be on `initiative/<id>`, the answer is the main tree —
28
+ * not a second folder for a branch that is already checked out. git allows one working tree
29
+ * per branch, so building a worktree here would fail, and failing is the better outcome only
30
+ * if the alternative were wrong. It isn't: the PM asked to work on that initiative and the
31
+ * repository is already there.
32
+ * * `create` is derived from whether the branch EXISTS, never assumed. `addWorktree(root, b,
33
+ * {create:true})` errors when the branch is already there, and `{create:false}` errors when it
34
+ * is not. Guessing produces a failed git operation, and a failed git operation is the one thing
35
+ * the rubric's veto forbids being followed by a spawned session.
36
+ *
37
+ * PURE ON PURPOSE. Everything it needs arrives as plain data, so the whole policy — including the
38
+ * paths that touch git — is exercisable in node with no repository, no socket and no PTY. The
39
+ * predecessor's hard floor ("a failed git operation must never start a session in the wrong
40
+ * directory") is inherited by this initiative, and a floor whose only proof is a manual click is
41
+ * not a floor.
42
+ */
43
+
44
+ const BRANCH_PREFIX = 'initiative/';
45
+
46
+ // The branch an initiative's work belongs on. One definition, so the client's preview and the
47
+ // server's git call cannot disagree about what the PM is about to get.
48
+ function branchForInitiative(id) {
49
+ const s = String(id == null ? '' : id).trim();
50
+ return s ? BRANCH_PREFIX + s : null;
51
+ }
52
+
53
+ // The repository's MAIN working tree among the registered ones. `repo_key` IS the main worktree's
54
+ // directory (repoIdentity resolves it from `git worktree list`, which names the main tree first),
55
+ // so the main tree is the one whose root equals the key. A repo where only LINKED worktrees are
56
+ // registered has no main tree on screen; the first registered tree stands in, which is the same
57
+ // fallback surfaceProducts already makes for the same reason.
58
+ function mainTreeOf(trees) {
59
+ const list = (trees || []).filter(Boolean);
60
+ if (!list.length) return null;
61
+ return list.find((t) => t.repo_key && t.root && String(t.root) === String(t.repo_key)) || list[0];
62
+ }
63
+
64
+ /* Resolve one "start work here" request to a plan.
65
+ *
66
+ * trees — the registered workspaces of ONE repository: { id, root, repo_key, branch }
67
+ * initiative — an initiative id, or null/'' for "just give me a terminal"
68
+ * branches — branch names known to the repo (branchInfo().branches), or null when unread
69
+ *
70
+ * Returns { ok, plan, wsId, branch, createBranch, isolate, why } — or { ok:false, error } when
71
+ * there is nothing to act on. `wsId` is the workspace the session is spawned against; `branch`
72
+ * non-null means a git operation is required first, and `isolate` says which kind.
73
+ */
74
+ function resolveStart({ initiative, trees, branches } = {}) {
75
+ const list = (trees || []).filter(Boolean);
76
+ if (!list.length) return { ok: false, error: 'no working tree is registered for this repository' };
77
+ const main = mainTreeOf(list);
78
+ if (!main) return { ok: false, error: 'no working tree is registered for this repository' };
79
+
80
+ const id = String(initiative == null ? '' : initiative).trim();
81
+ if (!id) {
82
+ return {
83
+ ok: true, plan: 'main-tree', wsId: main.id, branch: null, createBranch: false, isolate: false,
84
+ why: `runs in ${main.name || 'the main working tree'}${main.branch ? ` on ${main.branch}` : ''} — no branch is checked out and nothing else moves`,
85
+ };
86
+ }
87
+
88
+ const branch = branchForInitiative(id);
89
+ if (!branch) return { ok: false, error: 'an initiative id is required to derive a branch' };
90
+
91
+ // Already checked out in a LINKED worktree → go there. No git operation; the folder exists.
92
+ const existing = list.find((t) => t.id !== main.id && t.branch === branch);
93
+ if (existing) {
94
+ return {
95
+ ok: true, plan: 'reuse-worktree', wsId: existing.id, branch: null, createBranch: false, isolate: false,
96
+ why: `${id} already has its own folder — this reuses it, and creates nothing`,
97
+ };
98
+ }
99
+ // The MAIN tree is already on it. Use it rather than attempting a worktree git would refuse.
100
+ if (main.branch === branch) {
101
+ return {
102
+ ok: true, plan: 'main-tree-on-branch', wsId: main.id, branch: null, createBranch: false, isolate: false,
103
+ why: `this repository is already on ${branch} — the session runs here, and no second folder is made`,
104
+ };
105
+ }
106
+ // Otherwise: a new worktree, off the main tree, which stays exactly where it is.
107
+ // `create` is DERIVED. A null `branches` means the branch list was never read — assuming the
108
+ // branch is new would make `git worktree add -b` fail on every existing branch, so the honest
109
+ // default is to treat it as existing and let git's own error surface if it is not.
110
+ const known = Array.isArray(branches) ? branches : null;
111
+ const createBranch = known ? !known.includes(branch) : false;
112
+ return {
113
+ ok: true, plan: 'new-worktree', wsId: main.id, branch, createBranch, isolate: true,
114
+ why: `${branch} gets its own folder — ${main.name || 'the main working tree'} stays on ${main.branch || 'its branch'} and every running session stays put`,
115
+ };
116
+ }
117
+
118
+ module.exports = { resolveStart, branchForInitiative, mainTreeOf, BRANCH_PREFIX };
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
- // Who shares a working tree with whom, and who is DECLARED to be driving it.
2
+ // Who shares a working tree with whom, who is DECLARED to be driving it, and — since
3
+ // workspace-session-choice — what each of those sessions is actually DOING and what ending it
4
+ // would cost.
3
5
  //
4
6
  // The premise, stated once so nothing below has to hedge: PMOS hosts real PTYs and the agent CLI
5
7
  // inside one is untouched (D62). There is nothing between an agent and the filesystem, so this
@@ -21,6 +23,23 @@
21
23
  //
22
24
  // Pure functions over plain rows, on purpose: the server owns the git shell-outs and the session
23
25
  // registry, and the rules are the part worth testing without either.
26
+ //
27
+ // ── ONE module, TWO consumers ────────────────────────────────────────────────────────────────
28
+ // This file is now a UMD, for the same reason lib/session-state.js is: the SERVER computes the
29
+ // co-tenancy sentence at spawn, and the BROWSER computes the same descriptions again when the PM
30
+ // clicks `+ session` into an occupied tree. A second copy of "what does ending this cost?" would
31
+ // be free to drift, and the two surfaces would end up holding different opinions about one
32
+ // session — the exact failure the session-state table was extracted to stop.
33
+ //
34
+ // What this module deliberately does NOT do is name a session's STATE. The vocabulary (working ·
35
+ // quiet · needs you · maybe blocked · exited) belongs to lib/session-state.js and arrives here
36
+ // already worded, on the row, as `shown` + `label`. Re-deriving a word here would be that second
37
+ // copy in a new costume.
38
+ (function (root, factory) {
39
+ const api = factory();
40
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
41
+ else root.PMOSTreeShare = api;
42
+ })(typeof self !== 'undefined' ? self : this, function () {
24
43
 
25
44
  /** Live = a session the PM could still be driving. `exited` is a corpse and collides with nobody. */
26
45
  function isLive(s) { return !!s && s.state !== 'exited'; }
@@ -73,18 +92,138 @@ function nameList(rows, cap = 3) {
73
92
  return `${names.slice(0, cap).join(', ')} and ${names.length - cap} more`;
74
93
  }
75
94
 
95
+ /** The same list, each pane carrying the word beside it — `label` when the caller supplied one. */
96
+ function statedList(rows, cap = 3) {
97
+ const parts = (rows || []).map((s) => (s && s.label ? `“${s.name}” (${s.label})` : `“${s.name}”`));
98
+ if (parts.length <= cap) return parts.join(', ');
99
+ return `${parts.slice(0, cap).join(', ')} and ${parts.length - cap} more`;
100
+ }
101
+
102
+ /** A terse age, in the voice the sidebar already uses. `null` in, `null` out — never "0s". */
103
+ function ago(ms) {
104
+ if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
105
+ if (ms < 60000) return `${Math.max(1, Math.round(ms / 1000))}s`;
106
+ if (ms < 3600000) return `${Math.round(ms / 60000)}m`;
107
+ return `${Math.round(ms / 360000) / 10}h`;
108
+ }
109
+
110
+ /**
111
+ * WHAT ENDING THIS SESSION WOULD COST — read only from what the workspace can actually observe.
112
+ *
113
+ * The PM's question, asked in as many words, is "is it safe to end?". The honest answer has three
114
+ * shapes and only one of them is a yes, so the levels are named for the EVIDENCE rather than for a
115
+ * recommendation:
116
+ *
117
+ * 'in-flight' — bytes are arriving right now. Ending it cuts a turn that is running. Not safe,
118
+ * and this one PMOS is sure of.
119
+ * 'settled' — the turn finished and the session is waiting on the human (`needs`). This is the
120
+ * only state where "nothing is in flight" is CORROBORATED — session-state reaches
121
+ * `needs` only from a transcript with no unresolved tool call, over a quiet clock.
122
+ * 'unknown' — quiet, or a tool call open with nothing moving. The workspace cannot tell a
123
+ * finished turn from a permission prompt from a hung tool, and this is the state it
124
+ * must not launder into a yes. Saying "probably idle" is the whole point: an
125
+ * over-confident "safe" here is the watermelon this repo has a word for.
126
+ * 'gone' — already exited; there is nothing left to end.
127
+ *
128
+ * `shown` is the presented state from lib/session-state.js. An unrecognised value falls to
129
+ * 'unknown', because a state this module has never heard of is precisely not a state it may
130
+ * declare safe.
131
+ */
132
+ function endRisk(shown, quietMs) {
133
+ const age = ago(quietMs);
134
+ if (shown === 'exited') {
135
+ return { level: 'gone', why: 'already exited — there is nothing left to end' };
136
+ }
137
+ if (shown === 'working') {
138
+ return { level: 'in-flight', why: 'output is arriving right now — ending it cuts a turn that is still running' };
139
+ }
140
+ if (shown === 'needs') {
141
+ return {
142
+ level: 'settled',
143
+ why: 'its turn finished and it is waiting on you — ending it costs the scrollback, not work in flight',
144
+ };
145
+ }
146
+ if (shown === 'blocked') {
147
+ return {
148
+ level: 'unknown',
149
+ why: `a tool call has been open${age ? ` ${age}` : ''} with nothing moving — PMOS cannot tell a permission prompt from a stuck tool, so ending it may or may not discard work`,
150
+ };
151
+ }
152
+ return {
153
+ level: 'unknown',
154
+ why: `nothing has moved${age ? ` for ${age}` : ''}, and PMOS cannot see whether the turn finished — read this as “probably idle”, not “safe”`,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * One co-tenant, described for a human who is deciding between joining it and starting another.
160
+ *
161
+ * Everything here is either copied off the row or derived by `endRisk`; nothing is invented. In
162
+ * particular `doing` is the agent's own last words as the sidebar already shows them, and it stays
163
+ * null when the workspace could not read them rather than becoming a cheerful placeholder.
164
+ */
165
+ function describeTenant(row, writerId) {
166
+ const s = row || {};
167
+ const shown = s.shown || (s.state === 'exited' ? 'exited' : 'working');
168
+ return {
169
+ id: s.id || null,
170
+ ws: s.ws || null,
171
+ name: s.name || '(unnamed)',
172
+ agent: s.agent || null,
173
+ shown,
174
+ label: s.label || shown,
175
+ doing: s.doing || null,
176
+ branch: s.branch || null,
177
+ writer: !!(writerId && s.id === writerId),
178
+ risk: endRisk(shown, s.quietMs),
179
+ };
180
+ }
181
+
182
+ /**
183
+ * The whole answer to "you are about to start a session in a tree that is already busy".
184
+ *
185
+ * Returned as DATA rather than as a paragraph, because the two consumers need different shapes of
186
+ * it: the server writes a sentence into the pane's scrollback, and the browser draws a chooser with
187
+ * a button per peer. Both read the same `peers`, so a peer the chooser offers and a peer the pane
188
+ * line names can never be different sets.
189
+ */
190
+ function coTenancyReport(mates, opts) {
191
+ const o = opts || {};
192
+ const peers = (mates || []).map((m) => describeTenant(m, o.writerId));
193
+ const n = peers.length;
194
+ return {
195
+ tree: o.tree || null,
196
+ branch: o.branch || null,
197
+ peers,
198
+ headline: n
199
+ ? `${n} session${n === 1 ? '' : 's'} already running in this working tree`
200
+ : 'nothing else is running in this working tree',
201
+ // Stated once, here, so every surface that renders this report carries the same limit.
202
+ shared: 'you share one branch, one index and one set of files with '
203
+ + (n === 1 ? 'it' : 'them'),
204
+ advisory: 'Claims are advisory: PMOS hosts your agent’s terminal, it does not sit between it and the files.',
205
+ escape: 'To work in parallel for real, start the session on a new branch with its own folder (⑂).',
206
+ };
207
+ }
208
+
76
209
  /**
77
210
  * The sentence a session gets when it starts in a tree somebody is already working in.
78
211
  *
79
- * It names the panes (a bare count tells the PM nothing they can act on), states the ONE fact that
80
- * makes this matter — a shared branch, index and file set — and names the way out that already
81
- * exists, because a warning with no alternative is just a nag. Returns null when there is nobody
82
- * there, so the caller never has to test for emptiness.
212
+ * It names the panes AND what each of them is doing — a name alone tells the PM which pane, but not
213
+ * whether walking over to it is a good idea. `label` is the sidebar's own word for the session
214
+ * (working · quiet 12m · needs you · maybe blocked), passed in rather than re-derived, so this
215
+ * sentence and the sidebar cannot disagree. Rows with no label degrade to bare names rather than
216
+ * inventing a state.
217
+ *
218
+ * It states the ONE fact that makes this matter — a shared branch, index and file set — and names
219
+ * the way out that already exists, because a warning with no alternative is just a nag. Returns
220
+ * null when there is nobody there, so the caller never has to test for emptiness.
83
221
  */
84
222
  function coTenancyNotice(mates, opts) {
85
223
  const rows = mates || [];
86
224
  if (!rows.length) return null;
87
225
  const n = rows.length;
226
+ const rep = coTenancyReport(rows, opts);
88
227
  const role = opts && opts.role === 'writer' ? 'writer' : 'reader';
89
228
  const held = opts && opts.writerName;
90
229
  const who = role === 'writer'
@@ -94,23 +233,40 @@ function coTenancyNotice(mates, opts) {
94
233
  : held
95
234
  ? `“${held}” holds the writer claim`
96
235
  : 'no session holds the writer claim';
97
- return `${n} session${n === 1 ? '' : 's'} already running in this working tree (${nameList(rows)}) — `
98
- + `you share one branch, one index and one set of files with ${n === 1 ? 'it' : 'them'}. ${who}. `
99
- + 'Claims are advisory: PMOS hosts your agent’s terminal, it does not sit between it and the '
100
- + 'files. To work in parallel for real, start the session on a new branch with its own folder (⑂).';
236
+ return `${rep.headline} (${statedList(rows)}) — ${rep.shared}. ${who}. `
237
+ + `${rep.advisory} ${rep.escape} `
238
+ + `To join ${n === 1 ? 'it' : 'one of them'} instead of starting another, open it from + session — `
239
+ + 'the chooser says what each one is doing and what ending it would cost.';
101
240
  }
102
241
 
103
242
  /**
104
243
  * The same fact, written into the pane's own scrollback at spawn.
105
244
  *
106
245
  * The notice frame is a transient line in whichever client was listening; this one is part of the
107
- * session's record, so re-opening the tab tomorrow still shows what it was told. Dim, one line,
108
- * and it never claims anything was prevented.
246
+ * session's record, so re-opening the tab tomorrow still shows what it was told. Dim, and it never
247
+ * claims anything was prevented.
248
+ *
249
+ * It is no longer ONE line. The sentence is followed by a row per peer carrying that peer's state
250
+ * and what ending it would cost — the same `peers` the chooser draws, in the same order, because
251
+ * the PM who scrolls back to this tomorrow is asking the identical question they asked at the
252
+ * chooser and deserves the identical answer. Bounded at four rows so a twelve-session tree cannot
253
+ * push the shell's first prompt off the screen; the sentence above already says the true count.
109
254
  */
255
+ const ADVISORY_ROWS = 4;
110
256
  function paneAdvisory(mates, opts) {
111
257
  const line = coTenancyNotice(mates, opts);
112
258
  if (!line) return null;
113
- return `\x1b[38;5;244m⑂ ${line}\x1b[0m\r\n`;
259
+ const rep = coTenancyReport(mates, opts);
260
+ const shown = rep.peers.slice(0, ADVISORY_ROWS);
261
+ const rows = shown.map((p) => {
262
+ const who = `${p.name}${p.writer ? ' (writer)' : ''}`;
263
+ const doing = p.doing ? ` · ${p.doing}` : '';
264
+ return `\x1b[38;5;244m · ${who} — ${p.label}${doing}; ${p.risk.why}\x1b[0m\r\n`;
265
+ });
266
+ const more = rep.peers.length > shown.length
267
+ ? [`\x1b[38;5;244m · …and ${rep.peers.length - shown.length} more, listed in + session\x1b[0m\r\n`]
268
+ : [];
269
+ return `\x1b[38;5;244m⑂ ${line}\x1b[0m\r\n` + rows.join('') + more.join('');
114
270
  }
115
271
 
116
272
  /**
@@ -126,4 +282,6 @@ function joinNotices(...parts) {
126
282
  return kept.length ? kept.join(' · ') : null;
127
283
  }
128
284
 
129
- module.exports = { isLive, coTenants, writerOf, roleOf, coTenancyNotice, paneAdvisory, joinNotices, nameList };
285
+ return { isLive, coTenants, writerOf, roleOf, coTenancyNotice, paneAdvisory, joinNotices,
286
+ nameList, statedList, ago, endRisk, describeTenant, coTenancyReport, ADVISORY_ROWS };
287
+ });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.18.0",
9
+ "version": "0.19.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.18.0",
3
+ "version": "0.19.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",
@@ -10,7 +10,7 @@
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
12
  "test": "node test.js && npm run test:modules",
13
- "test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node no-network.test.js && node fixture-parity.test.mjs",
13
+ "test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node no-network.test.js && node fixture-parity.test.mjs && node start-policy.test.js",
14
14
  "test:ui": "node ui-test.js",
15
15
  "test:parity": "node design-parity.js --states",
16
16
  "test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
@@ -29,6 +29,12 @@
29
29
  holding different opinions about one session. Loaded before the module graph so a panel can
30
30
  bind from window.PMOSSessionState rather than re-typing a word. -->
31
31
  <script src="/lib/session-state.js"></script>
32
+ <!-- ONE module, TWO consumers, again (workspace-session-choice). Who shares a working tree, and
33
+ what ending one of those sessions would cost, is computed by the SERVER when it writes the
34
+ advisory into a new pane's scrollback and by the BROWSER when it draws the + session chooser.
35
+ Both read this file, so the sentence in the terminal and the sentence in the chooser cannot
36
+ drift apart. Classic script (a UMD setting window.PMOSTreeShare), like the two above. -->
37
+ <script src="/lib/tree-share.js"></script>
32
38
  <!-- The phone's navigation reducer. DOM-free, loaded as a classic script (it is a UMD that sets
33
39
  window.PMOSMobileNav) and required by test.js, so the whole screen domain is walkable in node
34
40
  rather than only in a browser. pm/mobile.js reads it from the global; the module graph does not