create-pmos 0.18.1 → 0.20.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.
@@ -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.1",
3
+ "version": "0.20.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.18.1",
9
+ "version": "0.20.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.18.1",
3
+ "version": "0.20.0",
4
4
  "private": true,
5
- "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
5
+ "description": "PMOS Terminal Workspace \u2014 local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "pmos": "bin/pmos.js"
@@ -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 && node start-policy.test.js",
13
+ "test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node external-linking.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,7 +29,7 @@
29
29
  "playwright-core": "^1.61.1",
30
30
  "ws": "^8.18.0"
31
31
  },
32
- "//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs — it just stops measuring pixels, and says so.",
32
+ "//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs \u2014 it just stops measuring pixels, and says so.",
33
33
  "devDependencies": {
34
34
  "pixelmatch": "^7.1.0",
35
35
  "pngjs": "^7.0.0"
@@ -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
@@ -83,9 +83,35 @@ function runningCount() {
83
83
 
84
84
  /* ── the shell, built once ─────────────────────────────────────────────────────────────────── */
85
85
 
86
+ /* ── Why every track and every column here is `minmax(0, …)` / `min-height:0` ─────────────────
87
+ *
88
+ * The shell is 100vh and nothing in it is supposed to scroll the PAGE — a panel scrolls, the app
89
+ * never does. That held right up until a long document was opened in panel 4, and then the whole
90
+ * user interface scrolled: the top bar slid away, the status bar came up from below, and the doc
91
+ * pane's own `overflow-y:auto` never engaged.
92
+ *
93
+ * The cause is one CSS default, in two guises, and it is worth naming precisely because it is
94
+ * invisible in a style string that looks correct:
95
+ *
96
+ * • `grid-template-rows: 44px 1fr 26px` — `1fr` is shorthand for `minmax(auto, 1fr)`, and that
97
+ * `auto` MINIMUM is the item's min-content height. A tall document therefore does not overflow
98
+ * the middle row; it ENLARGES it, past the 100vh the row was supposed to be trapped inside.
99
+ * • a grid item / flex item with `min-height: auto` cannot shrink below its content either, so
100
+ * even a correctly-sized track hands the overflow straight through to the next box down.
101
+ *
102
+ * Overflow that is `visible` still contributes to the SCROLLABLE overflow region of every ancestor
103
+ * that does not clip it, all the way up to the viewport — which is exactly the reported symptom:
104
+ * the page scrolls, not the pane. So the fix is the whole chain, not one box. Each ancestor between
105
+ * the viewport and a scrolling pane declares a zero minimum, and the root clips as the backstop so
106
+ * a future panel that forgets cannot reach the page again.
107
+ *
108
+ * The design prototype had this right (`grid-template-rows:44px minmax(0,1fr) 28px`, see
109
+ * prototype/workspace-layout-variants/*.html); the port dropped the `minmax(0, …)` and read
110
+ * identically until something tall was rendered into it.
111
+ */
86
112
  const root = el('div', {
87
113
  'data-pm-theme': state.theme,
88
- style: 'display:grid;grid-template-rows:44px 1fr 26px;height:100vh;background:var(--background);color:var(--foreground);font-family:var(--sans);font-size:13px;line-height:1.5',
114
+ style: 'display:grid;grid-template-rows:44px minmax(0,1fr) 26px;height:100vh;overflow:hidden;background:var(--background);color:var(--foreground);font-family:var(--sans);font-size:13px;line-height:1.5',
89
115
  });
90
116
 
91
117
  const themeBtn = el('button', {
@@ -113,7 +139,14 @@ const statusBar = el('div', {
113
139
  el('span', { style: 'margin-left:auto' }, 'sessions survive detach · ⌘K search · Esc closes tab'),
114
140
  );
115
141
 
116
- const bodyGrid = el('div', { style: 'display:grid;grid-template-columns:' + gridCols() + ';min-height:0' });
142
+ /* The body grid's own geometry, in one place so the initial build and every repaint below cannot
143
+ * disagree. The implicit ROW is stated explicitly as `minmax(0,1fr)` for the same reason the root's
144
+ * middle track is: an auto row is floored at its items' min-content height, and one tall panel
145
+ * would push the grid taller than the box it was given. */
146
+ const BODY_GRID_STYLE = () =>
147
+ 'display:grid;grid-template-columns:' + gridCols() + ';grid-template-rows:minmax(0,1fr);min-height:0;overflow:hidden';
148
+
149
+ const bodyGrid = el('div', { style: BODY_GRID_STYLE() });
117
150
 
118
151
  /** The collapsed 36px strip: a full-height button with a rotated vertical-rl label. */
119
152
  function makeRail(flag) {
@@ -131,10 +164,14 @@ const rail1 = makeRail('col1');
131
164
  const rail2 = makeRail('col2');
132
165
  rail0.label.textContent = 'repositories ⟩';
133
166
 
134
- const hostRepos = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;min-width:0' });
135
- const hostInits = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0' });
136
- const hostDetail = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;display:flex;flex-direction:column' });
137
- const hostTabs = el('div', { style: 'background:var(--term-bg);display:flex;flex-direction:column;min-width:0' });
167
+ /* `min-height:0` on all four: a grid item's automatic minimum is its content, so a panel taller
168
+ * than the viewport would otherwise stretch the row it sits in rather than scroll inside it. The
169
+ * two that already carry `overflow-y:auto` get it for free under the spec — stated anyway, because
170
+ * "this one is safe because of a rule elsewhere in the same line" is how the next edit breaks it. */
171
+ const hostRepos = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;min-width:0;min-height:0;overflow-y:auto' });
172
+ const hostInits = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0' });
173
+ const hostDetail = el('div', { style: 'background:var(--sidebar);border-right:1px solid var(--border);overflow-y:auto;min-width:0;min-height:0;display:flex;flex-direction:column' });
174
+ const hostTabs = el('div', { style: 'background:var(--term-bg);display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden' });
138
175
 
139
176
  setChildren(root, [topBar, bodyGrid, statusBar]);
140
177
 
@@ -172,7 +209,7 @@ function render() {
172
209
  rail1.label.textContent = modeTitle() + ' ⟩';
173
210
  rail2.label.textContent = detailTitle() + ' ⟩';
174
211
 
175
- bodyGrid.setAttribute('style', 'display:grid;grid-template-columns:' + gridCols() + ';min-height:0');
212
+ bodyGrid.setAttribute('style', BODY_GRID_STYLE());
176
213
 
177
214
  // On a phone there are no rails to collapse into and no second column to fold away: the four
178
215
  // hosts all stay mounted, one is DISPLAYED (pm/mobile.css picks it from `data-mpanel`), and the
@@ -277,9 +277,13 @@ function evalFor(ladderSteps) {
277
277
 
278
278
  /** A live session, in the shape panel 2 and panel 4 read. `shown` is the server's own derived state. */
279
279
  function sessionRow(s) {
280
- const label = s.shown === 'quiet' && Number.isFinite(s.quietMs)
280
+ // `s.label` is the SERVER's rendering of the presented state — one join (lib/session-state's
281
+ // `label`), one clock, one sentence. This used to be re-derived here from `shown` + `quietMs`,
282
+ // which is how the sidebar and the pane could describe one session with two different words. The
283
+ // local derivation survives only as a fallback for a server that predates the field.
284
+ const label = s.label || (s.shown === 'quiet' && Number.isFinite(s.quietMs)
281
285
  ? 'quiet ' + Math.max(1, Math.round(s.quietMs / 60000)) + 'm'
282
- : (s.shown === 'needs' ? 'needs you' : s.shown);
286
+ : (s.shown === 'needs' ? 'needs you' : s.shown));
283
287
  return { name: s.name, st: s.shown, label, profile: s.profile_id || null, id: s.id };
284
288
  }
285
289
 
@@ -441,10 +445,34 @@ function groupsFromSpine(spine) {
441
445
  const products = new Map(); // product name → {name, ws, root, count, board, records}
442
446
  let productOrder = [];
443
447
 
448
+ /* workspace id → the working-tree top it sits in (null for a folder with no git).
449
+ *
450
+ * This exists so panel 4 can answer "who else is already in the tree I am about to start a session
451
+ * in?" BEFORE it starts one. The session rows carry `tree` too, but only for trees that already
452
+ * have a session — which is one session too late for a chooser that runs on the FIRST click. The
453
+ * server derives this (`git rev-parse --show-toplevel`, via `repoIdentity`) and publishes it on
454
+ * /api/workspaces; it is read here and never re-derived. */
455
+ const wsTree = new Map();
456
+
457
+ /** The working tree a workspace id sits in, or null — for a folder with no git, or an unknown id. */
458
+ export function treeOfWorkspace(id) {
459
+ return (id && wsTree.get(id)) || null;
460
+ }
461
+
462
+ /** The branch that tree is on, or null. Shown beside the tree so a chooser names what is shared. */
463
+ const wsBranch = new Map();
464
+ export function branchOfWorkspace(id) {
465
+ return (id && wsBranch.get(id)) || null;
466
+ }
467
+
444
468
  /** Group registered working trees into products: several worktrees of one repo share a `repo_key`. */
445
469
  function foldWorkspaces(rows) {
446
470
  const byRepo = new Map();
447
471
  for (const w of rows || []) {
472
+ // Recorded per REGISTERED id, before the fold — folding is by repository, and the whole point
473
+ // of the tree key is that two registered ids can share one tree while a repo's two worktrees do
474
+ // not. Reading it off the folded row would lose exactly that distinction.
475
+ if (w && w.id) { wsTree.set(w.id, w.tree || null); wsBranch.set(w.id, w.branch || null); }
448
476
  const key = w.repo_key || w.root;
449
477
  if (!byRepo.has(key)) {
450
478
  byRepo.set(key, {
@@ -941,6 +969,18 @@ export async function recordArtifactLink({ artifact_path, slug, kind, target })
941
969
  return out;
942
970
  }
943
971
 
972
+ /**
973
+ * panel-detail.js — `lookupTask({kind, subject, hint, profile})`.
974
+ *
975
+ * Asks the server to compose the task a SESSION will run to resolve a reference. It returns text.
976
+ * Nothing here contacts Jira, Confluence or Google, and nothing here can: the workspace holds no
977
+ * credential (D53 — the agent layer acts with the PM's own MCP, PMOS records the result).
978
+ */
979
+ export async function lookupTask({ kind, subject, hint, profile }) {
980
+ if (FIXTURE) return { ok: false, error: 'fixture mode composes no task' };
981
+ return postJson('/api/initiative/lookup-task', { kind, subject, hint, profile });
982
+ }
983
+
944
984
  /** panel-detail.js — `recordInitiativeLink({slug, kind, label, path, st})` → `initiative-links.jsonl`. */
945
985
  export async function recordInitiativeLink({ slug, kind, label, path }) {
946
986
  if (FIXTURE) return { ok: true };
@@ -790,7 +790,30 @@ export const artDiffText = dest => 'repo file is the source. read ' + (dest ? de
790
790
  export const artSyncPushed = dest => ({ color: 'var(--success)', text: 'pushed · repo → ' + dest.kind + ' · in sync 12:41' });
791
791
  export const artSyncPulled = dest => ({ color: 'var(--warning)', text: 'pulled · ' + dest.kind + ' → repo · file updated' });
792
792
 
793
- /** addLink (lines 1757–1766) — the link a source button appends. */
793
+ /* addLink (lines 1757–1766) — PROTOTYPE TRANSCRIPTION ONLY. It has no caller, and must not gain one.
794
+ *
795
+ * This is the helper the whole workspace-external-linking initiative was opened about. It returns
796
+ * `{kind:'jira epic', label:'ATL-501 · <slug>', path:'jira://ATL-501', st:'connected'}` — a Jira key
797
+ * counted up from the constant 501, a label built from the initiative slug, and the word `connected`
798
+ * on a row nothing had read. panel-detail.js called it on every + click and wrote the result to
799
+ * `initiative-links.jsonl`, so PMOS accumulated references to epics that do not exist, rendered in
800
+ * the same visual grammar it uses for facts.
801
+ *
802
+ * WHY IT IS STILL HERE, WHICH IS A REAL TENSION AND NOT AN OVERSIGHT. The initiative's rubric asks
803
+ * for the fabricating helpers to be REMOVED rather than bypassed, and deleting this function is the
804
+ * literal reading. But this module is a TRANSCRIPTION of the prototype, and fixture-parity.test.mjs
805
+ * exists to pin it line-for-line — deleting the function deletes that suite's assertion, and the
806
+ * same rubric forbids weakening an assertion to make a change pass. Both rules are right and they
807
+ * point opposite ways.
808
+ *
809
+ * The property that actually matters is not this function's existence; it is that nothing in real
810
+ * mode can reach it. So it stays, as the pinned record of what the prototype drew, and the guarantee
811
+ * is enforced mechanically instead of by deletion: pm-detail-test.js asserts that no module outside
812
+ * this file references `makeLink`. If a later change wires it back up, that test fails and names it.
813
+ *
814
+ * The seeded rows below (`ATL-482` and its siblings) are the same case and are correct: a prototype
815
+ * draws specimens. They are rendered in FIXTURE mode only and never reach the real store.
816
+ */
794
817
  export function makeLink(slug, src, existing = []) {
795
818
  if (src === 'jira') {
796
819
  const num = 501 + existing.filter(x => x.kind === 'jira epic').length;