create-pmos 0.11.1 → 0.12.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,43 @@ 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.12.0
337
+
338
+ **The sidebar says what is actually true.** 0.11.x told you things were *unreadable* when they were
339
+ simply *not there* — and used the same red for both.
340
+
341
+ If you pointed the workspace at an ordinary folder or a repository that isn't a PMOS repo, almost
342
+ everything read as an error. None of it was.
343
+
344
+ - **A folder that isn't a git repository** now says so, quietly. It used to say `branch unreadable`,
345
+ which is an alarm about a completely ordinary directory.
346
+ - **A repository with no initiative records** reads **no initiatives yet** — a fact about the
347
+ repository, calmly stated. Pointing the workspace at any repo you like is a supported thing to do.
348
+ - **A shell session** no longer reports its non-existent transcript as a failed identification. A
349
+ shell is a prompt, not an agent narrating itself.
350
+ - **A row keeps its name.** Previously a plain folder rendered as a row titled `branch unreadable` —
351
+ a row with no name at all, unfindable in a tree of them.
352
+
353
+ Red still means red: a corrupt state queue, a genuine git failure, or an engine that is installed but
354
+ throws all still draw the alarm. The point is that they no longer share it with the normal case.
355
+
356
+ Three controls also stopped being ambiguous:
357
+
358
+ - **`+ new session — run an agent here`** now names the repository and branch it will start in.
359
+ "Here" meant the selected tree all along; with every repository on screen at once, nothing said
360
+ which that was.
361
+ - **`+ session here`** starts a session. It used to select the tree and stop.
362
+ - **A row with no sessions no longer expands** just because something needs your attention — that
363
+ count is already shown on the row, and opening an empty container to "reveal" it taught you to
364
+ distrust the tree.
365
+
366
+ And the pane is called **repos & sessions** rather than `work`.
367
+
368
+ > **One disclosure.** The Review Gate for this work was not scored before release. Its rubric makes
369
+ > the honesty checks *runnable* precisely so they cannot be satisfied by reading the code, and they
370
+ > were not run by an independent evaluator. Everything above was verified by the automated suites
371
+ > (412 checks) and by hand; none of it was independently reviewed.
372
+
336
373
  ## What's new in 0.11.1
337
374
 
338
375
  **Fixes: the ladder never actually worked.** If you installed 0.11.0 and every row in the new
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.11.1",
3
+ "version": "0.12.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@cae81d8
1
+ kernel-source: pmos@120ca72
2
2
  assembled: 2026-07-29
3
3
  mode: vendor
@@ -52,6 +52,8 @@ const EFFORT = /\beffort[:\s]+(low|medium|high|xhigh|max)\b/i;
52
52
  const TAIL_BYTES = 32_768;
53
53
 
54
54
  const NOT_CLAUDE = 'only claude sessions are parsed';
55
+ // A shell is a prompt, not an agent narrating itself. Absence here is the design, not a read failure.
56
+ const SHELL_NO_WORDS = 'a shell — it narrates nothing to read back';
55
57
  const UNREADABLE = "can't read its output — no recognisable Claude Code blocks";
56
58
 
57
59
  /** Split the scrubbed tail into lines, dropping a partial first line. */
@@ -153,7 +155,12 @@ function parseHarness(scrubbed, agent) {
153
155
  const out = { lastWords: null, tools: null, model: null, context: null, missing: {} };
154
156
 
155
157
  if (agent !== 'claude') {
156
- out.missing.lastWords = `can't read its last words — ${NOT_CLAUDE}`;
158
+ // R4 — a SHELL has no "last words" in the sense this parser means, and never will: it is a
159
+ // prompt, not an agent narrating itself. "can't read its last words" describes a failed attempt
160
+ // at something that was never going to happen, on the most ordinary pane in the app. State what
161
+ // it is instead. `codex` and any future agent keep the parser-scope message, which is the
162
+ // accurate thing to say about them: the transcript exists, this parser does not read it.
163
+ out.missing.lastWords = agent === 'shell' ? SHELL_NO_WORDS : `can't read its last words — ${NOT_CLAUDE}`;
157
164
  out.missing.tools = NOT_CLAUDE;
158
165
  out.missing.model = NOT_CLAUDE;
159
166
  out.missing.context = NOT_CLAUDE;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.11.1",
9
+ "version": "0.12.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.11.1",
3
+ "version": "0.12.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",
@@ -221,6 +221,7 @@ async function selectWorkspace(id, opts = {}) {
221
221
  if (changed) closeDoc();
222
222
  renderWorkspaces();
223
223
  renderSessions();
224
+ renderNewTarget(); // R5 — "here" moved; the composer must say so even if it is already open
224
225
  loadBoards();
225
226
  // focus a session in THIS workspace: the remembered one if it belongs here, else its first live one
226
227
  const mine = mySessions();
@@ -259,12 +260,43 @@ async function selectWorkspace(id, opts = {}) {
259
260
  */
260
261
  const TREE_GLYPH = { working: '●', quiet: '◼', blocked: '✗', idle: '—', exited: '○', none: '·' };
261
262
 
262
- // The initiative a branch names, and the record behind it — three cases, all of them stated. A
263
+ /* THE THREE-VALUED RULE (R2, and the veto of this initiative's rubric).
264
+ *
265
+ * Every derived cell in this tree is one of three things, and they get three renderings:
266
+ *
267
+ * value — the fact, rendered plainly
268
+ * verified absent— the read SUCCEEDED and found nothing. Quiet, specific, never red. This is the
269
+ * common case for anything that is not a PMOS repo, and it is the one the first
270
+ * version had no rendering for at all.
271
+ * unreadable — the read FAILED. Red, and it keeps the word.
272
+ *
273
+ * The first version had two renderings for three facts, so one was always mislabelled: a plain
274
+ * folder reported `branch unreadable`, which is an alarm about a completely ordinary directory, on
275
+ * the same surface that uses red for a corrupt state queue and a blocked initiative. A surface that
276
+ * cries wolf on the normal case teaches the PM to ignore red where red is real.
277
+ *
278
+ * `absent()` and `broken()` are the only two ways to say it, so a fourth site cannot invent a
279
+ * fifth vocabulary. */
280
+ const absent = (txt, title) => `<span class="gone"${title ? ` title="${esc(title)}"` : ''}>${esc(txt)}</span>`;
281
+ const broken = (txt, title) => `<span class="bl" role="alert"${title ? ` title="${esc(title)}"` : ''}>${esc(txt)}</span>`;
282
+
283
+ // The initiative a branch names, and the record behind it — four cases now, all of them stated. A
263
284
  // branch with no record is NOT an initiative with a missing file; it is a branch, and the row says
264
- // so rather than inventing an id (R2).
285
+ // so rather than inventing an id (R2). A workspace with no branch is either an ordinary folder that
286
+ // is not a repository (quiet) or a git read that failed (a fault) — never the same word for both.
287
+ // A ROW ALWAYS HAS AN IDENTITY. The first version used this label for both the row's name and its
288
+ // condition, so a plain folder rendered as a row literally titled `branch unreadable` — a row with
289
+ // no name at all, which is unfindable in a tree of them. The name is what the row IS; the condition
290
+ // is what is true of it, and it belongs on the second line with the other derived facts.
265
291
  function initiativeOf(w) {
266
292
  const br = w.branch;
267
- if (!br) return { kind: 'nobranch', label: w.branch_error ? 'branch unreadable' : 'detached HEAD', id: null };
293
+ if (!br) {
294
+ if (!w.branch_error) return { kind: 'detached', label: w.name, id: null };
295
+ // 'no-git' is not a failure to report a branch; it is a folder that HAS no branch. The message
296
+ // is branchInfo's own ("not a git repository") — this renderer does not compose its own prose.
297
+ if (w.branch_code === 'no-git') return { kind: 'nogit', label: w.name, id: null };
298
+ return { kind: 'branchbroken', label: w.name, id: null, condition: w.branch_error };
299
+ }
268
300
  const m = /^initiative\/(.+)$/.exec(br);
269
301
  if (!m) return { kind: 'branch', label: br, id: null };
270
302
  const id = m[1];
@@ -283,12 +315,15 @@ function boardRow(w, id) {
283
315
  // something needs the PM. Rows without a signal collapse to a count (R6). The PM's own toggle always
284
316
  // wins — `state.openTrees` holds it, and it is re-applied after every render for the same reason
285
317
  // `state.openHarness` is: this pane is rebuilt by innerHTML on every push.
318
+ // R7 — EXPANSION MUST BE EARNED. Signals divide in two, and the first version conflated them:
319
+ // * a signal about the TREE (a needs count, a blocker) belongs ON the row and is already rendered
320
+ // there — opening the container shows the PM nothing they cannot already see;
321
+ // * a signal about a SESSION is only visible once the container is open, so it earns the open.
322
+ // Conflating them is why a repository with one queued need and no sessions expanded to reveal a
323
+ // lone "+ session here" button. A row that opens onto nothing teaches you to distrust its glyphs.
286
324
  function treeHasSignal(w) {
287
325
  const mine = state.sessions.filter((s) => s.ws === w.id);
288
- if (state.needsByWs[w.id] && state.needsByWs[w.id].count) return true;
289
- if (state.needsByWs[w.id] && state.needsByWs[w.id].error) return true;
290
- const init = initiativeOf(w);
291
- if (init.row && init.row.blockers && init.row.blockers.length) return true;
326
+ if (!mine.length) return false; // nothing to reveal, so nothing to open for
292
327
  return mine.some((s) => {
293
328
  const shown = stateLabel(s).shown;
294
329
  return shown === 'working' || shown === 'blocked' || shown === 'exited';
@@ -340,6 +375,18 @@ function renderTree() {
340
375
  }).join('');
341
376
  host.innerHTML = html || `<div class="empty">No repositories — run <b>pmos</b> inside one.</div>`;
342
377
  host.querySelectorAll('[data-pick]').forEach((b) => b.onclick = () => selectWorkspace(b.dataset.pick));
378
+ // R6 — select the tree AND open the composer against it. Deliberately NOT awaited: selectWorkspace
379
+ // ends on four network reads (library · needs · branch · worktrees), and making the composer wait
380
+ // on them puts a variable delay between a click and any visible response — the failure mode the
381
+ // board cache was built to remove elsewhere. `state.wsId` is assigned synchronously at the top of
382
+ // selectWorkspace, so the form opens naming the RIGHT tree immediately; loadBranch() refills the
383
+ // branch picker when it lands, which is the same path the + button already relies on.
384
+ host.querySelectorAll('[data-start]').forEach((b) => b.onclick = (e) => {
385
+ e.stopPropagation();
386
+ const p = selectWorkspace(b.dataset.start);
387
+ openNewForm();
388
+ p.then(() => { if (!$('newForm').hidden) renderNewTarget(); });
389
+ });
343
390
  host.querySelectorAll('[data-repo-pick]').forEach((b) => b.onclick = () => openRepoDetail(b.dataset.repoPick));
344
391
  host.querySelectorAll('[data-forget]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); forgetWorkspace(b.dataset.forget); });
345
392
  host.querySelectorAll('[data-toggle]').forEach((b) => b.onclick = (e) => {
@@ -388,11 +435,30 @@ function treeRow(w, g) {
388
435
  } else if (board && board.pending) {
389
436
  wf = `<span class="wf"><span class="drv">deriving the ladder…</span></span>`;
390
437
  } else if (board && board.ok === false) {
391
- wf = `<span class="wf"><span class="bl">ladder unreadable</span></span>`;
438
+ // The one case that stays RED. The engine's own message says which fault it is — an install
439
+ // that carries no engine reads differently from one whose engine threw — so it is carried
440
+ // through as the tooltip rather than compressed into two words (incident
441
+ // kit-workflow-engine-missing: "ladder unreadable" was every adopter's entire experience of
442
+ // this surface, and it named neither the cause nor anything the PM could act on).
443
+ wf = `<span class="wf">${broken('ladder unreadable', board.error || '')}</span>`;
444
+ } else if (board && board.ok === true && board.empty === true) {
445
+ // VERIFIED ABSENT, and the sentence is about the REPOSITORY, not the branch: the engine read
446
+ // this repo successfully and it has no initiative records. Pointing the workspace at an
447
+ // ordinary repo is a supported thing to do, so this is the calm common case, not a defect.
448
+ wf = `<span class="wf">${absent('no initiatives yet', 'this repository has no planning/initiatives/ records — the workspace read it successfully')}</span>`;
392
449
  } else if (init.kind === 'norecord') {
393
- wf = `<span class="wf"><span class="drv">no initiative record</span></span>`;
450
+ // An initiative BRANCH whose record is missing — genuinely a gap in the repo, but not a failed
451
+ // read, so it stays quiet and says precisely which of the two it is.
452
+ wf = `<span class="wf">${absent('no record for this branch', `the branch names initiative "${init.id}" but planning/initiatives/${init.id}.md is not there`)}</span>`;
394
453
  } else if (init.kind === 'branch') {
395
- wf = `<span class="wf"><span class="drv">no initiative</span></span>`;
454
+ wf = `<span class="wf">${absent('not an initiative branch', `"${w.branch}" does not match initiative/<id>, so no ladder is derived for it`)}</span>`;
455
+ } else if (init.kind === 'nogit') {
456
+ wf = `<span class="wf">${absent('not a git repository', 'an ordinary folder — sessions run here, but nothing git-derived applies to it')}</span>`;
457
+ } else if (init.kind === 'branchbroken') {
458
+ // The other half of the split: git IS here and the read FAILED. This one keeps the alarm.
459
+ wf = `<span class="wf">${broken('branch unreadable', init.condition || '')}</span>`;
460
+ } else if (init.kind === 'detached') {
461
+ wf = `<span class="wf">${absent('detached HEAD', 'no branch is checked out, so no initiative is derived from one')}</span>`;
396
462
  }
397
463
  // the attribution line: which branch this ladder came from, and as of when
398
464
  const stamp = board && board.as_of ? new Date(board.as_of).toLocaleTimeString() : null;
@@ -400,8 +466,11 @@ function treeRow(w, g) {
400
466
  ? `<span class="attrib">derived on <b>${esc(w.branch || '?')}</b>${stamp ? ` · as of ${esc(stamp)}` : ''}${board && board.stale ? ' · refreshing' : ''}</span>`
401
467
  : '';
402
468
 
469
+ // R6 — the verb is performed. This used to call selectWorkspace() and stop, and its own tooltip
470
+ // admitted it ("select this tree, THEN start a session"). `data-start` selects the tree AND opens
471
+ // the composer against it, focused.
403
472
  const sessHtml = open ? mine.map((s) => sessionRow(s)).join('')
404
- + `<button class="tree-new" data-pick="${esc(w.id)}" title="select this tree, then start a session">+ session here</button>`
473
+ + `<button class="tree-new" data-start="${esc(w.id)}" title="start a session in ${esc(w.name)}">+ session here</button>`
405
474
  : '';
406
475
  const collapsed = !open && mine.length
407
476
  ? `<span class="rollup">${mine.length} session${mine.length === 1 ? '' : 's'}${live ? '' : ' · all exited'}</span>` : '';
@@ -413,9 +482,9 @@ function treeRow(w, g) {
413
482
  <button class="tr" data-pick="${esc(w.id)}" aria-current="${cur}" title="${esc(w.root)}">
414
483
  <span class="r1"><span class="g" aria-hidden="true">${TREE_GLYPH[gl] || '·'}</span>
415
484
  <span class="nm">${esc(init.label)}</span>
416
- ${w.home ? '<span class="tag">home</span>' : ''}
485
+ ${w.home ? `<span class="tag" title="the folder pmos was launched in — it is always hosted and cannot be removed">home</span>` : ''}
417
486
  <span class="dir">${esc(w.name === g.name ? 'main tree' : w.name)}</span></span>
418
- <span class="r2">${wf}${n && n.count ? `<span class="needs-n">${n.count} need you</span>` : ''}${n && n.error ? '<span class="bl">needs unreadable</span>' : ''}${collapsed}</span>
487
+ <span class="r2">${wf}${n && n.count ? `<span class="needs-n">${n.count} need you</span>` : ''}${n && n.error ? broken('needs unreadable', n.error) : ''}${collapsed}</span>
419
488
  ${attrib}
420
489
  </button>
421
490
  ${w.home ? '' : `<button class="forget" data-forget="${esc(w.id)}" aria-label="Stop hosting ${esc(w.name)}" title="${live ? 'end its sessions first' : 'stop hosting this repo (the repo is untouched)'}">✕</button>`}
@@ -2094,10 +2163,34 @@ function resolveDocPath(ctx, u) {
2094
2163
  }
2095
2164
 
2096
2165
  // ── new session: name it, pick its branch, before it starts ──
2166
+ /* R5 — "HERE" MUST HAVE A VISIBLE REFERENT.
2167
+ *
2168
+ * The composer acts on `state.wsId` and always did. That was legible when it lived underneath a
2169
+ * single selected workspace pane; once the sidebar became one tree showing every repository at
2170
+ * once, "run an agent here" named nothing on screen. This does not change WHAT it acts on (that
2171
+ * would be a behaviour change — PRD assumption A3); it makes the target sayable before the click.
2172
+ *
2173
+ * Called from three places, because the target changes without the form being reopened: on open, on
2174
+ * every workspace selection, and on every workspace push (a rename or a checkout moves the branch). */
2175
+ function newTargetText() {
2176
+ const w = state.workspaces.find((x) => x.id === state.wsId);
2177
+ if (!w) return { text: 'no tree selected — choose one below first', none: true };
2178
+ const init = initiativeOf(w);
2179
+ const where = init.kind === 'nogit' ? 'a plain folder' : (w.branch ? `on ${w.branch}` : 'no branch checked out');
2180
+ return { text: `in ${w.name} · ${where}`, none: false };
2181
+ }
2182
+ function renderNewTarget() {
2183
+ const el = $('newWhere');
2184
+ if (!el) return;
2185
+ const t = newTargetText();
2186
+ el.textContent = t.text;
2187
+ el.dataset.none = t.none ? '1' : '0';
2188
+ }
2097
2189
  function openNewForm() {
2098
2190
  const w = state.workspaces.find((x) => x.id === state.wsId);
2099
2191
  $('newForm').hidden = false;
2100
2192
  $('newBtn').setAttribute('aria-expanded', 'true');
2193
+ renderNewTarget();
2101
2194
  // pre-filled but editable — a default that reads better than "shell" in a list of shells
2102
2195
  const n = mySessions().length + 1;
2103
2196
  $('newName').value = `${w ? w.name : 'session'} ${n}`;
@@ -68,12 +68,17 @@
68
68
  <div class="cap">
69
69
  <span class="grip" id="sideGrip" data-pane="side" role="button" tabindex="0"
70
70
  aria-label="Move the sidebar" title="Move this column">⠿</span>
71
- <button class="fold" id="wsFold" data-fold="ws" aria-expanded="true" title="Hide the work tree"><span class="chev" aria-hidden="true">▾</span>work</button>
71
+ <!-- R8: the pane states its structure. It was called `work` — the least informative word
72
+ available for a tree of repositories, their working trees, and their sessions. -->
73
+ <button class="fold" id="wsFold" data-fold="ws" aria-expanded="true" title="Hide the repositories, their working trees and their sessions"><span class="chev" aria-hidden="true">▾</span>repos &amp; sessions</button>
72
74
  <span class="ct" id="needCount"></span>
73
75
  <button class="mini" id="addWsBtn" aria-expanded="false" title="Add a repository">+</button></div>
74
76
  <div class="pane-body">
75
77
  <button class="newsess" id="newBtn" aria-expanded="false"><i>+</i> new session — run an agent here</button>
76
78
  <form class="miniform" id="newForm" hidden>
79
+ <!-- R5: "here" gets a visible referent. The composer has always acted on the selected
80
+ tree; with every repository on screen at once, nothing said which that was. -->
81
+ <p class="where" id="newWhere"></p>
77
82
  <label class="lbl" for="newName">name this session</label>
78
83
  <input id="newName" type="text" placeholder="e.g. jira-sync build" aria-label="Session name" autocomplete="off" spellcheck="false" />
79
84
  <div class="seg" role="radiogroup" aria-label="Agent to run" id="newAgents">
@@ -1371,7 +1371,12 @@ line-height:1.35;cursor:pointer}
1371
1371
  .tr .wf .lane{color:var(--muted-foreground)}
1372
1372
  .tr .wf .stg{color:var(--brand)}
1373
1373
  .tr .wf .nx{color:var(--muted-foreground)}
1374
+ /* THE THREE-VALUED VOCABULARY (R2). Two renderings for three facts means one is always mislabelled
1375
+ — which is how an ordinary folder came to render as an alarm on the same surface that uses
1376
+ --destructive for a corrupt state queue. `.bl` keeps the alarm; `.gone` is the quiet, specific
1377
+ "the read succeeded and there is nothing here"; a value is styled as neither. */
1374
1378
  .tr .wf .bl,.tr .r2 .bl{color:var(--destructive)}
1379
+ .tr .wf .gone,.tr .r2 .gone{color:var(--muted-foreground);opacity:.8}
1375
1380
  .tr .wf .drv{color:var(--muted-foreground);font-style:italic}
1376
1381
  .tr .needs-n{color:var(--warning)}
1377
1382
  .tr .rollup{color:var(--muted-foreground)}
@@ -1384,6 +1389,11 @@ line-height:1.35;cursor:pointer}
1384
1389
  border:1px dashed var(--border);border-radius:var(--radius-sm);color:var(--muted-foreground);
1385
1390
  font:inherit;font-size:var(--text-2xs);cursor:pointer;text-align:left}
1386
1391
  .tree-new:hover{color:var(--foreground);border-color:var(--input)}
1392
+ /* R5 — the composer's target. "run an agent here" named nothing once every repository was on
1393
+ screen at once; this is the referent, and it updates on every selection change. */
1394
+ .miniform .where{font-family:var(--mono);font-size:var(--text-2xs);color:var(--muted-foreground);
1395
+ margin:0 0 6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1396
+ .miniform .where[data-none="1"]{color:var(--warning)}
1387
1397
  .cap .ct[data-bad="1"]{color:var(--destructive)}
1388
1398
 
1389
1399
  /* ── The rail's initiative dossier (R7·R8·R9) ────────────────────────────────────────────────── */
@@ -229,7 +229,7 @@ function repoIdentity(root) {
229
229
  const hit = repoKeyCache.get(root);
230
230
  const now = Date.now();
231
231
  if (hit && now - hit.at < REPO_KEY_TTL_MS) return hit;
232
- let key = root, name = path.basename(root), branch = null, branchError = null;
232
+ let key = root, name = path.basename(root), branch = null, branchError = null, branchCode = null;
233
233
  try {
234
234
  const trees = gitInfo.worktrees(root);
235
235
  if (trees.length && trees[0].dir) { key = path.resolve(trees[0].dir); name = path.basename(key); }
@@ -237,9 +237,16 @@ function repoIdentity(root) {
237
237
  try {
238
238
  const bi = gitInfo.branchInfo(root);
239
239
  if (bi.ok) branch = bi.detached ? null : bi.current;
240
- else branchError = bi.error || 'branch could not be read';
241
- } catch { branchError = 'branch could not be read'; }
242
- const val = { at: now, key, name, branch, branchError };
240
+ // R1/A2 — carry the CODE and the MESSAGE, not a boolean. branchInfo already separates the two
241
+ // failures and says so in its own comment: `no-git` is an ordinary folder and "deserves no
242
+ // alarm", `git-error` is something the PM may want to look at. Keeping only a truthy string here
243
+ // is what let the client render both as `branch unreadable` — an alarm on the normal case, on
244
+ // the same surface that uses red for a corrupt state queue. The renderer must not re-derive
245
+ // meaning from a boolean; a second place that decides what a failure MEANS is how the first
246
+ // one drifts.
247
+ else { branchError = bi.error || 'branch could not be read'; branchCode = bi.code || 'git-error'; }
248
+ } catch { branchError = 'branch could not be read'; branchCode = 'git-error'; }
249
+ const val = { at: now, key, name, branch, branchError, branchCode };
243
250
  repoKeyCache.set(root, val);
244
251
  return val;
245
252
  }
@@ -250,8 +257,11 @@ function wsList() {
250
257
  id: w.id, name: w.name, root: w.root, home: HOME_ENABLED && w.root === SELF_ROOT,
251
258
  shape: wsShape(w.root), addedAt: w.addedAt,
252
259
  sessions: [...sessions.values()].filter((s) => s.ws === w.id).length,
253
- // the repository this working tree belongs to, and the branch it is on
254
- repo_key: rid.key, repo_name: rid.name, branch: rid.branch, branch_error: rid.branchError,
260
+ // the repository this working tree belongs to, and the branch it is on. `branch_code`
261
+ // distinguishes an ordinary non-repo folder ('no-git') from a real git fault ('git-error') —
262
+ // the client renders the first quietly and the second as a fault (R1/R2).
263
+ repo_key: rid.key, repo_name: rid.name, branch: rid.branch,
264
+ branch_error: rid.branchError, branch_code: rid.branchCode,
255
265
  };
256
266
  });
257
267
  }
@@ -1018,7 +1028,18 @@ function transcriptHarness(s) {
1018
1028
  s.txFoundAt = now;
1019
1029
  }
1020
1030
  const found = s.txFound;
1021
- if (found.missing) return { missing: { all: found.missing } };
1031
+ if (found.missing) {
1032
+ // R4 — VERIFIED ABSENT IS NOT A FAILED SEARCH. A `shell` pane is walked for a descendant claude
1033
+ // because the PM often starts one there; when none is running, findTranscript's answer ("the CLI
1034
+ // wrote no session record") describes the SEARCH, and reads as a fault on a pane that is doing
1035
+ // exactly what a shell does. Nothing is wrong, nothing is missing, and there is nothing for the
1036
+ // PM to fix — so say what is true and what would change it. Every other reason (a record that is
1037
+ // unreadable, one belonging to another workspace) IS a fault and keeps its own words.
1038
+ if (s.agent === 'shell' && /wrote no session record/.test(found.missing)) {
1039
+ return { missing: { all: 'no agent is running in this shell — start one here and its harness appears', calm: true } };
1040
+ }
1041
+ return { missing: { all: found.missing } };
1042
+ }
1022
1043
  let stamp = null;
1023
1044
  try { const st = fs.statSync(found.file); stamp = `${st.size}:${st.mtimeMs}`; } catch {}
1024
1045
  if (s.tx && s.txStamp === stamp) return s.tx;
@@ -2879,6 +2900,10 @@ if (require.main === module) {
2879
2900
  }
2880
2901
  module.exports = { server, spawnSession, removeSession, renameSession, sessions, libraryTree, readTextDoc,
2881
2902
  registerWorkspace, forgetWorkspace, workspaces, library, initiativeIndex, fsList,
2903
+ // R1 — exported so the suite can assert the CARRY-THROUGH: branchInfo separates an ordinary
2904
+ // non-repo folder from a real git fault, and the whole legibility defect was that distinction
2905
+ // being dropped one layer above it.
2906
+ repoIdentity, wsList,
2882
2907
  // exported so the suite can assert the D08 invariant end-to-end: the harness parsers consume
2883
2908
  // scrubbed text, so a secret printed into scrollback must not survive into a rendered value.
2884
2909
  scrub, harnessOf,