create-pmos 0.34.0 → 0.36.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.
Files changed (82) hide show
  1. package/bin/create-pmos.js +15 -3
  2. package/package.json +1 -1
  3. package/payload/KIT-VERSION +2 -2
  4. package/payload/okf/core/concepts/agent-harness.md +2 -2
  5. package/payload/okf/core/concepts/design-loop.md +5 -5
  6. package/payload/okf/core/concepts/eval-driven-pm.md +1 -1
  7. package/payload/okf/core/concepts/okr-anti-optimism.md +1 -1
  8. package/payload/okf/core/concepts/okr-tree.md +2 -2
  9. package/payload/okf/core/concepts/output-eval.md +2 -2
  10. package/payload/okf/core/concepts/sdlc-loop.md +1 -1
  11. package/payload/okf/core/concepts/self-refining-harness.md +3 -3
  12. package/payload/okf/core/templates/content-brief-template.md +1 -1
  13. package/payload/okf/core/templates/design-brief-template.md +1 -1
  14. package/payload/okf/core/templates/eval-rubric-template.md +1 -1
  15. package/payload/okf/core/templates/log.md +1 -1
  16. package/payload/okf/core/templates/prd-template.md +1 -1
  17. package/payload/okf/core/templates/spike-brief-template.md +1 -1
  18. package/payload/scripts/frontier.sh +6 -2
  19. package/payload/scripts/gate-initiative.sh +24 -3
  20. package/payload/scripts/metrics.py +20 -4
  21. package/payload/scripts/read-jsonl.js +53 -0
  22. package/payload/scripts/workflow-state.js +144 -35
  23. package/payload/scripts/workflow-sweep.js +39 -22
  24. package/payload/skills/SKILL-FORMAT.md +1 -1
  25. package/payload/skills/copywriting.skill +1 -1
  26. package/payload/skills/correction-signal-derive.skill +1 -1
  27. package/payload/skills/design-brief.skill +4 -4
  28. package/payload/skills/design-reconcile.skill +1 -1
  29. package/payload/skills/discovery.skill +3 -3
  30. package/payload/skills/eval-rubric.skill +4 -4
  31. package/payload/skills/evaluator.skill +1 -1
  32. package/payload/skills/explain-run.skill +1 -1
  33. package/payload/skills/explain-surface.skill +6 -6
  34. package/payload/skills/harness-refine.skill +1 -1
  35. package/payload/skills/ingest-repo.skill +6 -6
  36. package/payload/skills/okr.skill +2 -2
  37. package/payload/skills/pmos-router.skill +8 -8
  38. package/payload/skills/prd.skill +5 -5
  39. package/payload/skills/prototype-grill.skill +8 -8
  40. package/payload/skills/prototype.skill +7 -7
  41. package/payload/skills/skill-creator.skill +1 -1
  42. package/payload/skills/source-to-concept.skill +1 -1
  43. package/payload/skills/ste-term-map.md +69 -0
  44. package/payload/skills/strategic-brief.skill +4 -4
  45. package/payload/skills/technical-writing-ste.skill +176 -0
  46. package/payload/skills-native/copywriting/SKILL.md +1 -1
  47. package/payload/skills-native/correction-signal-derive/SKILL.md +1 -1
  48. package/payload/skills-native/design-brief/SKILL.md +4 -4
  49. package/payload/skills-native/design-reconcile/SKILL.md +1 -1
  50. package/payload/skills-native/discovery/SKILL.md +3 -3
  51. package/payload/skills-native/eval-rubric/SKILL.md +4 -4
  52. package/payload/skills-native/evaluator/SKILL.md +1 -1
  53. package/payload/skills-native/explain-run/SKILL.md +1 -1
  54. package/payload/skills-native/explain-surface/SKILL.md +6 -6
  55. package/payload/skills-native/harness-refine/SKILL.md +1 -1
  56. package/payload/skills-native/ingest-repo/SKILL.md +6 -6
  57. package/payload/skills-native/okr/SKILL.md +2 -2
  58. package/payload/skills-native/pmos-router/SKILL.md +8 -8
  59. package/payload/skills-native/prd/SKILL.md +5 -5
  60. package/payload/skills-native/prototype/SKILL.md +7 -7
  61. package/payload/skills-native/prototype-grill/SKILL.md +8 -8
  62. package/payload/skills-native/skill-creator/SKILL.md +1 -1
  63. package/payload/skills-native/source-to-concept/SKILL.md +1 -1
  64. package/payload/skills-native/strategic-brief/SKILL.md +4 -4
  65. package/payload/skills-native/technical-writing-ste/SKILL.md +178 -0
  66. package/workspace/bin/pmos.js +9 -3
  67. package/workspace/lib/engine/read-jsonl.js +53 -0
  68. package/workspace/lib/engine/workflow-state.js +144 -35
  69. package/workspace/lib/file-mode-parse.js +19 -4
  70. package/workspace/lib/tree-share.js +69 -1
  71. package/workspace/package-lock.json +2 -2
  72. package/workspace/package.json +1 -1
  73. package/workspace/public/pm/board-read.js +38 -0
  74. package/workspace/public/pm/data.js +102 -1
  75. package/workspace/public/pm/modes.js +50 -28
  76. package/workspace/public/pm/panel-detail.js +2 -17
  77. package/workspace/public/pm/panel-initiatives.js +3 -20
  78. package/workspace/public/pm/panel-newsess.js +14 -1
  79. package/workspace/public/pm/panel-tabs.js +53 -14
  80. package/workspace/public/pm/tab-term.js +30 -2
  81. package/workspace/server.js +95 -70
  82. package/workspace/test-run.js +51 -9
@@ -47,6 +47,7 @@ import { el, setChildren } from './dom.js';
47
47
  import { state, setState, FIXTURE } from './state.js';
48
48
  import * as FX from './fixture.js';
49
49
  import { openDoc, openTerm } from './panel-initiatives.js';
50
+ import { boardOf, records, currentProduct } from './board-read.js';
50
51
 
51
52
  /* ── the prototype's small helpers ─────────────────────────────────────────────────────────── */
52
53
 
@@ -127,23 +128,7 @@ const knowMap = () => ({ ...(state.know || seedOr(() => FX.KNOW, {})), ...(state
127
128
  const profileList = () => state.profiles || seedOr(() => FX.PROFILES, []);
128
129
  const intgList = () => state.intgs || seedOr(() => FX.INTG, []);
129
130
 
130
- /* ── the board, read the way panels 2 and 3 read it ────────────────────────────────────────── */
131
-
132
- const boardOf = () =>
133
- (state.board && typeof state.board === 'object' ? state.board : (FIXTURE ? FX.ALLGROUPS : {}));
134
-
135
- function records() {
136
- const base = state.records && typeof state.records === 'object'
137
- ? state.records
138
- : (FIXTURE ? FX.DATA : {});
139
- return { ...base, ...state.userInits };
140
- }
141
-
142
- function currentProduct() {
143
- if (state.product) return state.product;
144
- const keys = Object.keys(boardOf());
145
- return keys.length ? keys[0] : 'workspace';
146
- }
131
+ /* ── the board, read the way panels 2 and 3 read it (board-read.js — one derivation) ───────── */
147
132
 
148
133
  /** The initiative a mode acts on when it starts a session (line 1397). */
149
134
  function selectedSlug() {
@@ -207,12 +192,12 @@ function focusList() {
207
192
  });
208
193
  /* The queue is a JUDGEMENT about what is blocked on the PM, so it is never invented here. In
209
194
  * fixture mode it is the prototype's four ranked items; in real mode it is whatever the data
210
- * layer published on `state.focus` ({first, waiting, syn}); with neither, each section renders
211
- * its label and one honest empty line. */
195
+ * layer published on `state.focus` (loadFocus's adapted `/api/console?name=focus` view —
196
+ * {first, waiting, syn, saw}, or {readError} for a failed read); with neither, each section
197
+ * renders its label and one honest empty line. */
212
198
  const F = state.focus || (FIXTURE
213
199
  ? { first: FX.FOCUS_FIRST, waiting: FX.FOCUS_WAITING, syn: FX.FOCUS_SYN }
214
200
  : { first: [], waiting: [], syn: [] });
215
- const live = FIXTURE || !!state.focus;
216
201
  const first = (F.first || []).map(fRow);
217
202
  const waiting = (F.waiting || []).map(fRow);
218
203
  const MEM = memoryOf();
@@ -220,14 +205,20 @@ function focusList() {
220
205
  const syn = (F.syn || []).map((f) => ({
221
206
  ...f,
222
207
  on: () => {
223
- if (f.action === 'delegate-billing-stories') {
208
+ /* The delegate spawn is the FIXTURE's own seed session. A real-mode row never carries this
209
+ * action (data.js renders the server's `starts` without a button — a button would claim a
210
+ * spawn this surface has no runner for), so the gate is stated, not assumed. */
211
+ if (FIXTURE && f.action === 'delegate-billing-stories') {
224
212
  const d = FX.DELEGATE_SESSION;
225
213
  startSession(d.slug, { ...d.sess });
226
214
  }
227
215
  },
228
216
  }));
229
217
 
230
- const saw = live ? FX.focusSaw(MEM).map((f) => ({
218
+ /* Upkeep is data-driven like the other sections: the fixture seed renders ONLY in fixture mode;
219
+ * in real mode the rows are the server's own upkeep view, adapted by data.js. Seed rows behind a
220
+ * published `state.focus` were "the beautiful shell" waiting to activate. */
221
+ const saw = (FIXTURE ? FX.focusSaw(MEM) : (F.saw || [])).map((f) => ({
231
222
  ...f,
232
223
  on: () => {
233
224
  if (f.action === 'mode:memory') { setState({ mode: 'memory' }); return; }
@@ -236,7 +227,7 @@ function focusList() {
236
227
  jump(prod2, slug);
237
228
  }
238
229
  },
239
- })) : [];
230
+ }));
240
231
 
241
232
  const board = boardOf();
242
233
  const ends = productTuples().map(([id]) => {
@@ -244,6 +235,21 @@ function focusList() {
244
235
  return g0 ? { prod: id, obj: g0.gid + ' · ' + g0.gname } : null;
245
236
  }).filter(Boolean);
246
237
 
238
+ /* A failed read renders as a failed read, and an in-flight one says so — never "a successful
239
+ * read of nothing" for either. Used by the four sections the focus payload feeds; the objectives
240
+ * section derives from the board and keeps its own empty line. */
241
+ const emptyOr = () => {
242
+ if (F.readError) {
243
+ return el('div', { style: 'padding:5px 14px 6px;font-size:11px;color:var(--destructive)' },
244
+ iv('read failed — ' + F.readError));
245
+ }
246
+ if (F.reading) {
247
+ return el('div', { style: 'padding:5px 14px 6px;font-size:11px;color:var(--muted-foreground);font-style:italic' },
248
+ 'reading the focus queue…');
249
+ }
250
+ return emptyLine();
251
+ };
252
+
247
253
  const out = [];
248
254
  out.push(el('div', { style: 'padding:8px 14px 4px;font-size:11.5px;color:var(--muted-foreground);line-height:1.5' },
249
255
  'This list holds only items where ',
@@ -270,7 +276,7 @@ function focusList() {
270
276
  el('span', { style: 'display:block;font-size:11px;margin-top:1px' }, iv(ff.why)),
271
277
  el('span', { style: 'display:block;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(ff.read))));
272
278
  }
273
- if (!first.length) out.push(emptyLine());
279
+ if (!first.length) out.push(emptyOr());
274
280
 
275
281
  out.push(el('div', { style: LABEL9 }, 'handoffs · waiting on other teams'));
276
282
  for (const fw of waiting) {
@@ -281,7 +287,15 @@ function focusList() {
281
287
  el('span', { style: 'display:block;font-size:11px;color:var(--muted-foreground);margin-top:1px' }, iv(fw.why)),
282
288
  el('span', { style: 'display:block;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(fw.read))));
283
289
  }
284
- if (!waiting.length) out.push(emptyLine());
290
+ /* `waitingNote` is the server's own sentence for a tracker that is NOT CONNECTED — a different
291
+ * fact from a successful read that found nothing, so it gets the server's words, not the calm
292
+ * empty line. */
293
+ if (!waiting.length) {
294
+ out.push(F.waitingNote
295
+ ? el('div', { style: 'padding:5px 14px 6px;font-size:11px;color:var(--muted-foreground);font-style:italic' },
296
+ iv(F.waitingNote))
297
+ : emptyOr());
298
+ }
285
299
 
286
300
  out.push(el('div', { style: LABEL9 }, 'delegated · agents in parallel'));
287
301
  for (const fy of syn) {
@@ -292,7 +306,7 @@ function focusList() {
292
306
  style: 'flex:none;font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:26px;background:var(--brand);color:oklch(0.987 0.002 197.1);font-weight:500',
293
307
  }, iv(fy.btn))));
294
308
  }
295
- if (!syn.length) out.push(emptyLine());
309
+ if (!syn.length) out.push(emptyOr());
296
310
 
297
311
  out.push(el('div', { style: LABEL9 }, 'upkeep · keep the system sharp'));
298
312
  for (const fs2 of saw) {
@@ -302,7 +316,7 @@ function focusList() {
302
316
  hover: ROW_HOVER,
303
317
  }, el('span', { style: 'display:block;font-size:11.5px;color:var(--muted-foreground);line-height:1.45' }, iv(fs2.t))));
304
318
  }
305
- if (!saw.length) out.push(emptyLine());
319
+ if (!saw.length) out.push(emptyOr());
306
320
  return out;
307
321
  }
308
322
 
@@ -668,12 +682,20 @@ function schedBody(det) {
668
682
  onWhen: (e) => save({ ...t, when: e.target.value }),
669
683
  onRunCmd: (e) => save({ ...t, runCmd: e.target.value }),
670
684
  onScope: (e) => save({ ...t, scope: e.target.value }),
685
+ /* The green "ok — ran … 0 failures" line is the FIXTURE's seed log. In real mode nothing
686
+ * executes on this press — no runner is wired to this surface — so writing it there would be
687
+ * a fabricated execution success (the watermelon the honesty contract forbids). The real-mode
688
+ * line says exactly what happened: nothing. */
671
689
  onRun: () => save({
672
690
  ...t,
673
- log: [{
691
+ log: [FIXTURE ? {
674
692
  ts: '12:41 today',
675
693
  t: t.runCmd ? 'ok — ran `' + t.runCmd + '` manually, 0 failures' : 'nothing ran — no command defined yet',
676
694
  c: t.runCmd ? 'var(--success)' : 'var(--warning)',
695
+ } : {
696
+ ts: '—',
697
+ t: 'nothing ran — no runner is wired to this surface yet',
698
+ c: 'var(--warning)',
677
699
  }, ...(t.log || [])],
678
700
  }),
679
701
  onToggle: () => save({ ...t, enabled: !t.enabled }),
@@ -54,6 +54,7 @@ import { state, setState, FIXTURE } from './state.js';
54
54
  import * as FX from './fixture.js';
55
55
  import { openDoc, openTab, openTerm, fileNewModel } from './panel-initiatives.js';
56
56
  import { addProductBody } from './panel-repos.js';
57
+ import { boardOf, records, currentProduct } from './board-read.js';
57
58
 
58
59
  /* ── the prototype's small helpers ─────────────────────────────────────────────────────────── */
59
60
 
@@ -102,23 +103,7 @@ function declare(fn, payload) {
102
103
  if (typeof write === 'function') { try { write(payload); } catch { /* the UI still shows it */ } }
103
104
  }
104
105
 
105
- /* ── the board, read the same way panel 2 reads it ─────────────────────────────────────────── */
106
-
107
- const boardOf = () =>
108
- (state.board && typeof state.board === 'object' ? state.board : (FIXTURE ? FX.ALLGROUPS : {}));
109
-
110
- function records() {
111
- const base = state.records && typeof state.records === 'object'
112
- ? state.records
113
- : (FIXTURE ? FX.DATA : {});
114
- return { ...base, ...state.userInits };
115
- }
116
-
117
- function currentProduct() {
118
- if (state.product) return state.product;
119
- const keys = Object.keys(boardOf());
120
- return keys.length ? keys[0] : 'workspace';
121
- }
106
+ /* ── the board, read the same way panel 2 reads it (board-read.js — one derivation) ────────── */
122
107
 
123
108
  function baseGroups(product) {
124
109
  const board = boardOf();
@@ -70,6 +70,7 @@
70
70
  import { el, setChildren } from './dom.js';
71
71
  import { state, setState, FIXTURE } from './state.js';
72
72
  import * as FX from './fixture.js';
73
+ import { boardOf, records, currentProduct } from './board-read.js';
73
74
 
74
75
  /* ── the prototype's small helpers, verbatim in behaviour ──────────────────────────────────── */
75
76
 
@@ -82,20 +83,8 @@ const selRow = (on) => ({
82
83
  bl: on ? 'var(--brand)' : 'transparent',
83
84
  });
84
85
 
85
- /* The seed board is reachable ONLY in fixture mode. In normal mode an unpublished board is an
86
- * EMPTY board — the prototype's own copy for a repository with nothing filed — never the seed data.
87
- * "The beautiful shell" in the PRD's failure-mode list is exactly this: fixture rows rendering in
88
- * the real app, so the diff looks clean while nothing is connected. */
89
- const seedBoard = () => (FIXTURE ? FX.ALLGROUPS : {});
90
- const seedRecords = () => (FIXTURE ? FX.DATA : {});
91
- const boardOf = () => (state.board && typeof state.board === 'object' ? state.board : seedBoard());
92
-
93
- /** The repository whose board is showing (line 1395). */
94
- function currentProduct() {
95
- if (state.product) return state.product;
96
- const keys = Object.keys(boardOf());
97
- return keys.length ? keys[0] : 'workspace';
98
- }
86
+ /* `boardOf` / `records` / `currentProduct` live in board-read.js — one derivation, four readers.
87
+ * The seed-board honesty rule ("the beautiful shell") is stated and enforced there. */
99
88
 
100
89
  /** The Objective/KR groups a repository starts from, before user-filed additions. */
101
90
  function baseGroups(product) {
@@ -106,12 +95,6 @@ function baseGroups(product) {
106
95
  return FIXTURE && !state.board ? (FX.ALLGROUPS[product] || FX.ALLGROUPS.workspace) : [];
107
96
  }
108
97
 
109
- /** Every initiative record: the board's own records, with user-filed ones layered on (line 1392). */
110
- function records() {
111
- const base = state.records && typeof state.records === 'object' ? state.records : seedRecords();
112
- return { ...base, ...state.userInits };
113
- }
114
-
115
98
  /** GROUPS (lines 1425–1430) — user groups appended, user KRs and user initiatives folded in. */
116
99
  function groupsFor(product) {
117
100
  const extraGroups = (state.userGroups && state.userGroups[product]) || [];
@@ -97,6 +97,9 @@ export function openNewSession(slug, seed) {
97
97
  newSessAgent: s.agent || 'shell',
98
98
  newSessBranch: '', newSessBranchNew: '', newSessBranchMode: false,
99
99
  newSessIsolate: null, newSessError: null, newSessBusy: false,
100
+ // Cleared so a form opened on a DIFFERENT repository never shows the previous repo's branch
101
+ // list while its own read is out — "reading the repository…" is true; a stale list is not.
102
+ newSessBranches: null,
100
103
  // Cleared on the way IN as well as on the way out: a form that opened still showing the profile
101
104
  // a previous, abandoned question had chosen would be proposing an answer nobody gave.
102
105
  preflightProfile: s.profile === undefined ? null : s.profile,
@@ -135,9 +138,17 @@ function proposeName(slug) {
135
138
 
136
139
  /* Fired when the form opens, and again when the server says a branch moved. Both reads are
137
140
  * independent and each writes only its own key, so a repository with no git still gets its profile
138
- * chips and a machine with no profile directory still gets its branch picker. */
141
+ * chips and a machine with no profile directory still gets its branch picker.
142
+ *
143
+ * `readsFor` records WHICH open the reads were actually issued for. The lazy data.js import above
144
+ * can land after the form opened — refreshReads then returned early and nothing ever retried, so
145
+ * the form sat on "reading the repository…" for ever. The render path re-issues the reads once per
146
+ * open (newSessionBlock); the token is what stops it re-fetching on every repaint. */
147
+ let readsFor = null;
148
+
139
149
  function refreshReads() {
140
150
  if (FIXTURE || !dataMod) return;
151
+ readsFor = state.newSess;
141
152
  const ws = state.wsId || null;
142
153
  if (typeof dataMod.loadBranches === 'function') {
143
154
  dataMod.loadBranches(ws).then((r) => setState({ newSessBranches: r || null })).catch(() => {});
@@ -451,6 +462,8 @@ async function onStart() {
451
462
  /** The pre-flight block, or null when it is closed. Called from panel-tabs.js's render. */
452
463
  export function newSessionBlock() {
453
464
  if (!state.newSess) return null;
465
+ // The reads this open still owes — issued here when data.js resolved after openNewSession ran.
466
+ if (!FIXTURE && dataMod && readsFor !== state.newSess) refreshReads();
454
467
  absorbBranchBroadcast();
455
468
  const slug = state.newSess.slug;
456
469
  const busy = !!state.newSessBusy;
@@ -33,6 +33,7 @@ import { openTab, openTerm as openTermTab, openDoc as openDocTab } from './panel
33
33
  import { mountDoc, disposeDoc, docText } from './tab-doc.js';
34
34
  import { mountTask } from './tab-task.js';
35
35
  import { newSessionBlock, openNewSession } from './panel-newsess.js';
36
+ import { boardOf, currentProduct } from './board-read.js';
36
37
 
37
38
  /* tab-term.js is loaded LAZILY and on purpose: it is another module's file, a static import of a
38
39
  * file that is not on disk yet takes the whole panel down with it, and an empty terminal pane is a
@@ -45,16 +46,7 @@ import('./tab-term.js')
45
46
  /** `{{ expr }}` — one interpolated value in its own span, the way the prototype's runtime emits it. */
46
47
  const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v));
47
48
 
48
- /* ── the board, read exactly the way panels 2 and 3 read it ─────────────────────────────────── */
49
-
50
- const boardOf = () =>
51
- (state.board && typeof state.board === 'object' ? state.board : (FIXTURE ? FX.ALLGROUPS : {}));
52
-
53
- function currentProduct() {
54
- if (state.product) return state.product;
55
- const keys = Object.keys(boardOf());
56
- return keys.length ? keys[0] : 'workspace';
57
- }
49
+ /* ── the board, read exactly the way panels 2 and 3 read it (board-read.js) ─────────────────── */
58
50
 
59
51
  /** The selected initiative (prototype line 1397) — the slug `+ session` starts a session on. */
60
52
  function selSlug() {
@@ -85,7 +77,11 @@ function visibleTabs() {
85
77
  const map = slugProductOf();
86
78
  return (state.tabs || []).filter((t) => {
87
79
  const sp = map[t.slug] !== undefined ? map[t.slug] : t.repo;
88
- return sp === undefined || sp === product;
80
+ /* `== null` on purpose: an unattributed-session tab carries `slug: null, repo: null`
81
+ * (panel-initiatives openTerm), and a strict undefined check excluded it from EVERY
82
+ * repository's bar — an open, PTY-attached tab with no visible chip and no close button. A
83
+ * tab on no board is visible everywhere, whichever null-ish shape says so. */
84
+ return sp == null || sp === product;
89
85
  });
90
86
  }
91
87
 
@@ -645,6 +641,8 @@ const CH_BTN = 'flex:none;font-family:var(--mono);font-size:10.5px;padding:4px 1
645
641
 
646
642
  function peerRow(prompt, p) {
647
643
  const armed = state.endConfirm === p.id;
644
+ const refusal = state.endRefusal && state.endRefusal.id === p.id ? state.endRefusal.why : null;
645
+ const asked = state.endAsked === p.id;
648
646
  return el('div', {
649
647
  style: 'display:flex;gap:10px;align-items:flex-start;padding:8px 10px;border:1px solid oklch(1 0 0 / 10%);background:oklch(1 0 0 / 3%)',
650
648
  },
@@ -664,6 +662,17 @@ function peerRow(prompt, p) {
664
662
  style: 'font-family:var(--mono);font-size:10.5px;color:oklch(0.62 0.02 213);overflow:hidden;text-overflow:ellipsis;white-space:nowrap',
665
663
  }, iv(p.doing)) : null,
666
664
  el('div', { style: 'font-size:11px;color:' + RISK_COLOR[p.risk.level] + ';line-height:1.45' }, iv(p.risk.why)),
665
+ /* WHY THE END DID NOT HAPPEN, in the row the PM was looking at. A refusal recorded in state
666
+ * and rendered nowhere would be the same silence in a new place — the whole defect here was a
667
+ * failure that looked like a success. */
668
+ refusal ? el('div', {
669
+ style: 'font-size:11px;color:var(--destructive);line-height:1.45',
670
+ }, iv('not ended — ' + refusal)) : null,
671
+ /* And the honest reading of a SENT end: asked, not done. The row leaves this list when the
672
+ * server stops reporting the session, and that is the confirmation. */
673
+ asked ? el('div', {
674
+ style: 'font-size:11px;color:oklch(0.62 0.02 213);line-height:1.45',
675
+ }, iv('asked it to end — this row disappears when the session actually stops')) : null,
667
676
  ),
668
677
  el('div', { style: 'flex:none;display:flex;gap:6px;align-items:center' },
669
678
  el('button', {
@@ -672,9 +681,39 @@ function peerRow(prompt, p) {
672
681
  }, 'open it'),
673
682
  el('button', {
674
683
  onClick: () => {
675
- if (!armed) { setState({ endConfirm: p.id }); return; }
676
- if (termMod && typeof termMod.killSession === 'function') termMod.killSession(p.id);
677
- setState({ endConfirm: null });
684
+ if (!armed) { setState({ endConfirm: p.id, endRefusal: null }); return; }
685
+ /* THE ANSWER IS READ, NOT DISCARDED. This was an unconditional `setState({endConfirm:null})`
686
+ * after a call whose return value was thrown away, so three separate paths — no module, a
687
+ * `FIXTURE || !id` refusal, and a closed socket — disarmed the button on a request that
688
+ * never left the page. The third is the one an ordinary PM meets: in a fresh window with
689
+ * no terminal tab the chooser is populated over HTTP while this socket has never opened.
690
+ *
691
+ * The decision itself is in lib/tree-share so the BLOCKING suite can assert it; a rule
692
+ * that lived here would only ever be tested by the browser suites, which CI runs under
693
+ * `continue-on-error: true` — they report and never block. */
694
+ const TS = typeof window !== 'undefined' ? window.PMOSTreeShare : null;
695
+ const hasModule = !!(termMod && typeof termMod.killSession === 'function');
696
+ const at = TS && typeof TS.endAttempt === 'function'
697
+ ? TS.endAttempt({
698
+ id: p.id,
699
+ hasModule,
700
+ socketOpen: !!(termMod && typeof termMod.socketOpen === 'function' && termMod.socketOpen()),
701
+ fixture: FIXTURE,
702
+ })
703
+ /* The module that decides is the same module that built this row, so it cannot be
704
+ * missing here. If it ever is, refuse — a destructive control has no safe default. */
705
+ : { send: false, why: 'the workspace could not decide whether this could be sent, so it was not' };
706
+ if (!at.send) {
707
+ /* STAYS ARMED. Re-arming is the only signal the PM has that nothing happened, so a
708
+ * refusal must not clear `endConfirm` — the disarm was itself the false report. */
709
+ setState({ endRefusal: { id: p.id, why: at.why } });
710
+ return;
711
+ }
712
+ termMod.killSession(p.id);
713
+ /* `asked`, never `ended`. All this page knows is that a frame was written to a socket;
714
+ * whether the session died is the server's to report, and the row leaving the list is
715
+ * what says so. */
716
+ setState({ endConfirm: null, endRefusal: null, endAsked: p.id });
678
717
  },
679
718
  style: CH_BTN + (armed ? ';color:var(--destructive);border-color:var(--destructive)' : ''),
680
719
  hover: CHIP_HOVER,
@@ -256,9 +256,15 @@ export function requestSpawn(opts) {
256
256
  };
257
257
  SOCK.spawnQueue.push(req);
258
258
  /* Armed BEFORE the flush, and armed whether or not the message goes out now: a request waiting
259
- * for a socket that never opens must end in a sentence, not in a form that spins for ever. */
259
+ * for a socket that never opens must end in a sentence, not in a form that spins for ever.
260
+ *
261
+ * Only an UNSENT ask leaves the queue here — the server never heard it, so no answer is coming.
262
+ * A SENT one keeps its slot, settled: the queue's ORDER is the correlation, and removing a slot
263
+ * the server will still answer (spawnWithBranch can legitimately outlive this timer on a large
264
+ * repo) would hand its late `created` to the next request in line — a different click's PTY.
265
+ * The `created` handler detaches the session a dead slot's answer names. */
260
266
  req.timer = setTimeout(() => {
261
- SOCK.spawnQueue = SOCK.spawnQueue.filter((x) => x !== req);
267
+ if (!req.sent) SOCK.spawnQueue = SOCK.spawnQueue.filter((x) => x !== req);
262
268
  settleAsk(req, {
263
269
  ok: false,
264
270
  error: req.sent
@@ -369,6 +375,11 @@ function handle(m) {
369
375
  const req = takeSent(true);
370
376
  if (!req) return;
371
377
  if (req.kind === 'ask') {
378
+ // A slot the timeout already settled has no caller left: the form reported the failure and
379
+ // moved on, so the session this answer names is unowned — detach it rather than leak a
380
+ // running PTY nobody asked for. Consuming the slot (above) is what keeps the NEXT answer
381
+ // correlated with the next request.
382
+ if (req.settled) { send({ t: 'detach', id: m.id }); return; }
372
383
  // Nobody is on screen waiting for this. Hand the caller the id and whatever non-fatal truth
373
384
  // arrived just before it; the caller opens the tab, and the tab carries the id so bind()
374
385
  // ATTACHES rather than starting a second session for the same click.
@@ -925,6 +936,23 @@ export function killSession(id) {
925
936
  return send({ t: 'kill', id });
926
937
  }
927
938
 
939
+ /**
940
+ * Is the terminal socket open right now?
941
+ *
942
+ * Exported so a caller can decide BEFORE acting rather than discover afterwards. `killSession`
943
+ * already returned `false` on a closed socket and its own docstring said a caller should use that
944
+ * to say "not sent" — the chooser discarded it, and a destructive control disarmed on a request
945
+ * that never left the page. The pre-flight is what lets the chooser refuse and SAY WHY, and the
946
+ * reason has to be distinguishable from "the module is not loaded", which the return value alone
947
+ * cannot express.
948
+ *
949
+ * The readiness constant is `1` (`OPEN`) and nothing else: `CONNECTING` is not open, and a frame
950
+ * written to a `CLOSING` socket is a frame nobody receives.
951
+ */
952
+ export function socketOpen() {
953
+ return !!(SOCK.ws && SOCK.ws.readyState === 1);
954
+ }
955
+
928
956
  /**
929
957
  * Write a status line into a tab's terminal, by tab id — for a caller that holds a fact about a
930
958
  * session but not the pane showing it.