create-pmos 0.7.3 → 0.9.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.
@@ -0,0 +1,139 @@
1
+ /* session-state — the presented state of a session, joined from the workspace's TWO sources.
2
+ * (workspace-waiting-on-you R2/R3/R4/R5/R6.)
3
+ *
4
+ * One source of truth for "what word goes beside this session", used on BOTH sides:
5
+ * - server.js requires it (sessionList publishes the joined state)
6
+ * - the browser loads it as /lib/session-state.js before app.js (window.PMOSSessionState)
7
+ * so the sidebar and the server can never hold different opinions about one session. That is not
8
+ * hypothetical tidiness: QUIET_AFTER_MS was once re-typed as a literal in app.js, two copies of one
9
+ * number free to drift, and the fix was to ship it from one place. A duplicated TABLE fails the
10
+ * same way with more surface area.
11
+ *
12
+ * ── THE TWO SOURCES, AND WHY NEITHER IS ENOUGH ALONE ────────────────────────────────────────
13
+ * CADENCE (harness-parse.deriveState ← lastOutputAt) answers "is anything happening?"
14
+ * STRUCTURE (transcript-read.turn ← the JSONL) answers "whose turn is it?"
15
+ *
16
+ * Cadence alone cannot tell a finished turn from a crash from a slow tool — which is why
17
+ * workspace-harness-tower's R1 forbade upgrading `quiet` to "waiting on you", and why that rule is
18
+ * SATISFIED here rather than repealed: the stronger words are built from BOTH, and every cell
19
+ * without corroboration still says exactly what it said before.
20
+ *
21
+ * The two contributions are NOT symmetric, and pretending otherwise would be the first step toward
22
+ * over-claiming:
23
+ * - for `needs` the transcript is load-bearing and the quiet clock is only a SETTLE-TIMER
24
+ * (the terminal is still painting the final block for a moment after a turn ends)
25
+ * - for `blocked` the transcript only says a call is open; the quiet clock does the real
26
+ * discriminating, because a running tool emits bytes and a permission prompt does not
27
+ *
28
+ * ── THE PROPERTY THIS FILE EXISTS TO GUARANTEE (R4) ─────────────────────────────────────────
29
+ * `needs` is reachable ONLY from turn === 'user', and transcript-read sets 'user' only when NO
30
+ * tool_use is unresolved. A crashed session's dangling call is a permanent phantom (2/25 real
31
+ * transcripts) and a phantom IS an unresolved call — so a phantom can only ever reach `tool`, and
32
+ * `needs` is UNCONSTRUCTABLE for it.
33
+ *
34
+ * There is deliberately NO `if (!phantom)` anywhere here. A check is a thing a later change can
35
+ * forget to call; a state that cannot be built needs no discipline to stay honest. This is the same
36
+ * discipline as the shipped "age without liveness is refused" — and if you are editing this file,
37
+ * the test that matters is `needs you` over the join's WHOLE domain, not one example of it.
38
+ */
39
+ 'use strict';
40
+ (function (root, factory) {
41
+ const api = factory();
42
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
43
+ else root.PMOSSessionState = api;
44
+ })(typeof self !== 'undefined' ? self : this, function () {
45
+
46
+ // The cadence clock's three values (harness-parse.deriveState) — this module's first input.
47
+ const CADENCE = ['working', 'quiet', 'exited'];
48
+ // The transcript's answer to whose turn it is — this module's second input. `null` = no answer,
49
+ // which is the NORMAL case for a shell/codex session and for any claude session whose transcript
50
+ // cannot be resolved. It is not an error and draws no apology.
51
+ const TURN = ['user', 'agent', 'tool', null];
52
+ // What can end up on screen. The first three are pre-existing and unchanged; the last two are
53
+ // this feature. `failed` is absent by design — it has no observable (harness-tower).
54
+ const PRESENTED = ['working', 'quiet', 'exited', 'needs', 'blocked'];
55
+
56
+ // The word beside the glyph. Deliberately separate from the key: `blocked`'s word names its own
57
+ // uncertainty, because its evidence cannot distinguish a permission prompt from a hung tool from
58
+ // a corpse. The surface may not launder that into confidence.
59
+ const WORD = {
60
+ working: 'working',
61
+ quiet: 'quiet',
62
+ exited: 'exited',
63
+ needs: 'needs you',
64
+ blocked: 'maybe blocked',
65
+ };
66
+
67
+ // Filled = corroborated by both sources, hollow = suspected from one. `needs` takes ◆, which has
68
+ // sat declared-but-dead in the client's GLYPH map since the harness tower; this makes it live.
69
+ const GLYPH = { working: '●', quiet: '◼', exited: '○', needs: '◆', blocked: '◇' };
70
+
71
+ // Which presented states may carry the open call's verb + age. Unchanged for `working` (shipped
72
+ // behaviour); extended to `blocked`, where the age is the most informative part of a weak claim —
73
+ // "a call has been open 6m and nothing is moving" is the whole signal.
74
+ const CARRIES_CALL_AGE = { working: true, blocked: true };
75
+
76
+ /**
77
+ * THE JOIN (R2). Total over CADENCE × TURN: every one of the twelve cells is defined here, and
78
+ * `presentedStates()` below exists so a test can walk the domain rather than sample it.
79
+ *
80
+ * Pure: no clock read, no I/O, no DOM. The caller supplies the already-derived cadence state, so
81
+ * this function cannot disagree with `deriveState` about the threshold — it never sees one.
82
+ *
83
+ * turn ↓ / cadence → working quiet exited
84
+ * 'user' working NEEDS exited
85
+ * 'tool' working BLOCKED exited
86
+ * 'agent' working quiet exited
87
+ * null working quiet exited
88
+ *
89
+ * Three cells change from pre-feature behaviour. The other nine are byte-identical, and that is
90
+ * asserted rather than assumed.
91
+ */
92
+ function presented(cadence, turn) {
93
+ // `exited` is the server's own lifecycle fact and outranks anything the transcript says: a
94
+ // transcript is a file that outlives its process, so a dead session's last record would
95
+ // otherwise keep claiming a turn forever.
96
+ if (cadence === 'exited') return 'exited';
97
+ // Bytes are arriving. Whatever the transcript says, something IS happening, so no stronger
98
+ // claim is available or needed — and this is what stops `needs` firing during the moment the
99
+ // terminal is still painting the turn it just finished.
100
+ if (cadence === 'working') return 'working';
101
+ if (cadence !== 'quiet') return 'working'; // unknown cadence ⇒ the weakest live word, never a claim
102
+ // Quiet. Only here can the transcript raise the claim above `quiet`.
103
+ if (turn === 'user') return 'needs'; // turn ended AND nothing moving ⇒ the human is up
104
+ if (turn === 'tool') return 'blocked'; // a call is open AND nothing moving ⇒ suspect
105
+ return 'quiet'; // 'agent' (model call in flight) or null (no source)
106
+ }
107
+
108
+ /** Every (cadence, turn, presented) triple. The domain is small — walk it, don't sample it. */
109
+ function presentedStates() {
110
+ const out = [];
111
+ for (const cadence of CADENCE) {
112
+ for (const turn of TURN) out.push({ cadence, turn, presented: presented(cadence, turn) });
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** Does this presented state make the strong "this session wants you" claim? */
118
+ function isNeedsClaim(p) { return p === 'needs'; }
119
+
120
+ /**
121
+ * The full label for a row or tab: the word, plus the quiet clock's age on `quiet`, plus the open
122
+ * call's verb and age where the state carries it. `fmtDur` is injected (the client and the server
123
+ * format durations for different audiences) and the ages are computed by the CALLER, because this
124
+ * module reads no clock.
125
+ */
126
+ function label(p, { quietMs = null, call = null, fmtDur = null } = {}) {
127
+ const w = WORD[p] || p;
128
+ const d = (ms) => (fmtDur && ms != null ? fmtDur(ms) : null);
129
+ if (p === 'quiet') { const s = d(quietMs); return s ? `${w} ${s}` : w; }
130
+ if (CARRIES_CALL_AGE[p] && call && call.verb) {
131
+ const s = d(call.ageMs);
132
+ return s ? `${w} · ${call.verb} ${s}` : `${w} · ${call.verb}`;
133
+ }
134
+ return w;
135
+ }
136
+
137
+ return { CADENCE, TURN, PRESENTED, WORD, GLYPH, CARRIES_CALL_AGE,
138
+ presented, presentedStates, isNeedsClaim, label };
139
+ });
@@ -168,7 +168,7 @@ function prettyTool(name) {
168
168
  */
169
169
  function readHarness(file, clean = (x) => x) {
170
170
  const out = { model: null, effort: null, turns: 0, tokens: null, tools: null, skills: null,
171
- title: null, openCall: null, missing: {} };
171
+ title: null, openCall: null, turn: null, missing: {} };
172
172
  let raw;
173
173
  try {
174
174
  const st = fs.statSync(file);
@@ -199,6 +199,11 @@ function readHarness(file, clean = (x) => x) {
199
199
  // report the raw fact {verb, sinceTs}; we never claim it is live.
200
200
  const openUse = new Map(); // tool_use_id → { verb, ts } (insertion order = chronological)
201
201
  const resolved = new Set(); // tool_use_ids that got a tool_result
202
+ // WHOSE TURN IS IT (workspace-waiting-on-you R1). The last TURN-TAKING record's role — nothing
203
+ // more. `ai-title` and `attachment` records are CLI bookkeeping and are deliberately not
204
+ // turn-taking, and neither is a SYNTHETIC assistant record: counting one as "the assistant
205
+ // finished speaking" is exactly how a false `needs you` would be manufactured.
206
+ let lastRole = null; // 'assistant' | 'user' | null
202
207
 
203
208
  for (const line of raw.split('\n')) {
204
209
  if (!line) continue;
@@ -217,6 +222,7 @@ function readHarness(file, clean = (x) => x) {
217
222
  for (const c of (Array.isArray(um.content) ? um.content : [])) {
218
223
  if (c && c.type === 'tool_result' && c.tool_use_id) resolved.add(c.tool_use_id);
219
224
  }
225
+ lastRole = 'user'; // a prompt was submitted, or a tool result landed
220
226
  continue;
221
227
  }
222
228
  if (r.type === 'attachment') {
@@ -234,6 +240,7 @@ function readHarness(file, clean = (x) => x) {
234
240
  if (m.model && m.model !== SYNTHETIC) {
235
241
  out.model = clean(m.model);
236
242
  out.turns++; // only real model turns count
243
+ lastRole = 'assistant'; // ...and only a real turn takes the turn
237
244
  if (r.effort) out.effort = clean(String(r.effort));
238
245
  }
239
246
  const u = m.usage || {};
@@ -260,6 +267,19 @@ function readHarness(file, clean = (x) => x) {
260
267
  if (!resolved.has(id)) out.openCall = { verb: info.verb, sinceTs: info.ts };
261
268
  }
262
269
 
270
+ // ── Whose turn (R1). Derived here, from what the pass already collected — no second walk. ──
271
+ // The ORDER of these three branches is the honesty property the whole feature rests on (R4).
272
+ // `tool` is tested FIRST, so any unresolved call claims the turn. A crashed session's dangling
273
+ // tool_use is a PERMANENT phantom (2/25 real transcripts), and a phantom is by definition an
274
+ // unresolved call — so a phantom always lands on `tool`, and `user` (the only turn from which
275
+ // the strong `needs you` claim can be built) is UNREACHABLE for it. Not a guard that rejects
276
+ // the phantom: a value it cannot construct. Reordering these branches silently converts a
277
+ // corpse into "waiting on you", which is the one lie this surface must not tell.
278
+ if (out.openCall) out.turn = 'tool'; // awaiting a tool — OR the PM's approval of one
279
+ else if (lastRole === 'assistant') out.turn = 'user'; // the model stopped; the human is up
280
+ else if (lastRole === 'user') out.turn = 'agent'; // a prompt/result landed; the model is up
281
+ // else: null — nothing turn-taking was parseable, so no claim is made.
282
+
263
283
  if (!out.model) out.missing.model = 'the transcript names no model yet';
264
284
  if (!out.effort) out.missing.effort = 'the transcript records no effort level';
265
285
  if (toolTotal) out.tools = { counts: toolCounts, total: toolTotal };
@@ -271,11 +291,13 @@ function readHarness(file, clean = (x) => x) {
271
291
  else out.missing.tokens = 'the transcript records no token usage yet';
272
292
  if (!out.skills) out.missing.skills = 'the CLI listed no skills for this session';
273
293
  if (!out.turns) out.missing.turns = 'no model turns in the transcript yet';
274
- // NOTE: `title` and `openCall` deliberately get NO `missing` entry. This map carries reasons the
275
- // UI RENDERS in place of a value; both of these instead fall back to a real shipped rendering
276
- // (agent · cwd for an untitled session; the plain `working`/`quiet` word when no call is open).
277
- // Putting them here would break the map's contract — "empty missing == everything was readable" —
278
- // and put a reason on screen where a perfectly good value already goes.
294
+ // NOTE: `title`, `openCall` and `turn` deliberately get NO `missing` entry. This map carries
295
+ // reasons the UI RENDERS in place of a value; all three instead fall back to a real shipped
296
+ // rendering (agent · cwd for an untitled session; the plain `working`/`quiet` word when no call
297
+ // is open and when the turn is unknown). Putting them here would break the map's contract —
298
+ // "empty missing == everything was readable" — and put a reason on screen where a perfectly good
299
+ // value already goes. For `turn` that fallback IS the feature's refusal rule (R5): no second
300
+ // source, no claim, and the row reads exactly as it did before this existed.
279
301
 
280
302
  return out;
281
303
  }