create-pmos 0.34.0 → 0.37.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 (83) hide show
  1. package/bin/create-pmos.js +15 -3
  2. package/package.json +1 -1
  3. package/payload/AGENTS.md +2 -0
  4. package/payload/KIT-VERSION +2 -2
  5. package/payload/okf/core/concepts/agent-harness.md +2 -2
  6. package/payload/okf/core/concepts/design-loop.md +5 -5
  7. package/payload/okf/core/concepts/eval-driven-pm.md +1 -1
  8. package/payload/okf/core/concepts/okr-anti-optimism.md +1 -1
  9. package/payload/okf/core/concepts/okr-tree.md +2 -2
  10. package/payload/okf/core/concepts/output-eval.md +2 -2
  11. package/payload/okf/core/concepts/sdlc-loop.md +1 -1
  12. package/payload/okf/core/concepts/self-refining-harness.md +3 -3
  13. package/payload/okf/core/templates/content-brief-template.md +1 -1
  14. package/payload/okf/core/templates/design-brief-template.md +1 -1
  15. package/payload/okf/core/templates/eval-rubric-template.md +1 -1
  16. package/payload/okf/core/templates/log.md +1 -1
  17. package/payload/okf/core/templates/prd-template.md +1 -1
  18. package/payload/okf/core/templates/spike-brief-template.md +1 -1
  19. package/payload/scripts/frontier.sh +6 -2
  20. package/payload/scripts/gate-initiative.sh +24 -3
  21. package/payload/scripts/metrics.py +20 -4
  22. package/payload/scripts/read-jsonl.js +53 -0
  23. package/payload/scripts/workflow-state.js +144 -35
  24. package/payload/scripts/workflow-sweep.js +39 -22
  25. package/payload/skills/SKILL-FORMAT.md +1 -1
  26. package/payload/skills/copywriting.skill +1 -1
  27. package/payload/skills/correction-signal-derive.skill +1 -1
  28. package/payload/skills/design-brief.skill +4 -4
  29. package/payload/skills/design-reconcile.skill +1 -1
  30. package/payload/skills/discovery.skill +3 -3
  31. package/payload/skills/eval-rubric.skill +4 -4
  32. package/payload/skills/evaluator.skill +1 -1
  33. package/payload/skills/explain-run.skill +1 -1
  34. package/payload/skills/explain-surface.skill +6 -6
  35. package/payload/skills/harness-refine.skill +1 -1
  36. package/payload/skills/ingest-repo.skill +6 -6
  37. package/payload/skills/okr.skill +2 -2
  38. package/payload/skills/pmos-router.skill +8 -8
  39. package/payload/skills/prd.skill +5 -5
  40. package/payload/skills/prototype-grill.skill +8 -8
  41. package/payload/skills/prototype.skill +7 -7
  42. package/payload/skills/skill-creator.skill +1 -1
  43. package/payload/skills/source-to-concept.skill +1 -1
  44. package/payload/skills/ste-term-map.md +69 -0
  45. package/payload/skills/strategic-brief.skill +4 -4
  46. package/payload/skills/technical-writing-ste.skill +176 -0
  47. package/payload/skills-native/copywriting/SKILL.md +1 -1
  48. package/payload/skills-native/correction-signal-derive/SKILL.md +1 -1
  49. package/payload/skills-native/design-brief/SKILL.md +4 -4
  50. package/payload/skills-native/design-reconcile/SKILL.md +1 -1
  51. package/payload/skills-native/discovery/SKILL.md +3 -3
  52. package/payload/skills-native/eval-rubric/SKILL.md +4 -4
  53. package/payload/skills-native/evaluator/SKILL.md +1 -1
  54. package/payload/skills-native/explain-run/SKILL.md +1 -1
  55. package/payload/skills-native/explain-surface/SKILL.md +6 -6
  56. package/payload/skills-native/harness-refine/SKILL.md +1 -1
  57. package/payload/skills-native/ingest-repo/SKILL.md +6 -6
  58. package/payload/skills-native/okr/SKILL.md +2 -2
  59. package/payload/skills-native/pmos-router/SKILL.md +8 -8
  60. package/payload/skills-native/prd/SKILL.md +5 -5
  61. package/payload/skills-native/prototype/SKILL.md +7 -7
  62. package/payload/skills-native/prototype-grill/SKILL.md +8 -8
  63. package/payload/skills-native/skill-creator/SKILL.md +1 -1
  64. package/payload/skills-native/source-to-concept/SKILL.md +1 -1
  65. package/payload/skills-native/strategic-brief/SKILL.md +4 -4
  66. package/payload/skills-native/technical-writing-ste/SKILL.md +178 -0
  67. package/workspace/bin/pmos.js +9 -3
  68. package/workspace/lib/engine/read-jsonl.js +53 -0
  69. package/workspace/lib/engine/workflow-state.js +144 -35
  70. package/workspace/lib/file-mode-parse.js +19 -4
  71. package/workspace/lib/tree-share.js +69 -1
  72. package/workspace/package-lock.json +2 -2
  73. package/workspace/package.json +1 -1
  74. package/workspace/public/pm/board-read.js +38 -0
  75. package/workspace/public/pm/data.js +102 -1
  76. package/workspace/public/pm/modes.js +50 -28
  77. package/workspace/public/pm/panel-detail.js +2 -17
  78. package/workspace/public/pm/panel-initiatives.js +3 -20
  79. package/workspace/public/pm/panel-newsess.js +14 -1
  80. package/workspace/public/pm/panel-tabs.js +53 -14
  81. package/workspace/public/pm/tab-term.js +30 -2
  82. package/workspace/server.js +95 -70
  83. package/workspace/test-run.js +51 -9
@@ -687,9 +687,19 @@ function budgetsFor(A, root) {
687
687
  // or a DECISIONS.md gets the initiative lens + curated knowledge; anything else is a plain document
688
688
  // tree. Detection is nested-aware so a repo that vendors a PMOS bundle under a sub-folder still
689
689
  // shows its initiatives (the reported defect) — including a kit whose records are its whole bundle.
690
+ // Memoised for the same reason repoIdentity is: wsList() calls this for every workspace on every
691
+ // push, and a cold answer for a plain repo costs up to four depth-2 walks. A repo that gains its
692
+ // first planning/ dir reads as 'plain' for at most one TTL — the staleness class the branch chip
693
+ // already accepts.
694
+ const wsShapeCache = new Map(); // root -> { at, shape }
690
695
  function wsShape(root) {
691
- return planningRoots(root).length || initiativeDirs(root).length
696
+ const hit = wsShapeCache.get(root);
697
+ const now = Date.now();
698
+ if (hit && now - hit.at < REPO_KEY_TTL_MS) return hit.shape;
699
+ const shape = planningRoots(root).length || initiativeDirs(root).length
692
700
  || fs.existsSync(path.join(root, 'DECISIONS.md')) ? 'pmos' : 'plain';
701
+ wsShapeCache.set(root, { at: now, shape });
702
+ return shape;
693
703
  }
694
704
 
695
705
  // ── Library: the file tree the sidebar browses. ───────────────────────────────────────
@@ -1400,18 +1410,14 @@ function deriveState(ev) {
1400
1410
  if (ev.runs) return { state: 'building', why: `${ev.runs} run${ev.runs > 1 ? 's' : ''} logged, nothing merged yet` };
1401
1411
  return { state: 'planning', why: 'no run, no merge, no acceptance — nothing has happened yet' };
1402
1412
  }
1403
- // BOTH acceptance ledgers, oldest-wins-never: the workspace writes to its state dir, while the
1404
- // ladder and every hand-recorded acceptance in pmos-self live in planning/runs/. Reading only the
1405
- // state dir is why this surface reported 0 accepted against 23 real acceptance rows — the same
1406
- // "one field, several readers, none agreeing" defect the anchor resolver was built to end.
1407
- function acceptanceFilesFor(w) {
1408
- return ledgerFilesFor(w, 'acceptances.jsonl');
1409
- }
1410
- // The general form of the comment above, because the defect it describes was never specific to
1411
- // acceptances. `deriveNeeds` read evals.jsonl, acceptances.jsonl AND outcome_checks.jsonl from the
1412
- // state dir alone, so the supervision surface under-reported everything pmos-self records in
1413
- // planning/runs/ — the same "one field, several readers, none agreeing" shape, three ledgers wide.
1414
- // One helper with three call sites, so the fourth ledger added later cannot reintroduce it.
1413
+ // BOTH ledger locations, always: the workspace writes to its state dir, while the ladder and every
1414
+ // hand-recorded acceptance in pmos-self live in planning/runs/. Reading only the state dir is why
1415
+ // the initiative surface reported 0 accepted against 23 real acceptance rows, and the defect was
1416
+ // never specific to acceptances: `deriveNeeds` read evals.jsonl, acceptances.jsonl AND
1417
+ // outcome_checks.jsonl from the state dir alone, so the supervision surface under-reported
1418
+ // everything pmos-self records in planning/runs/ — the same "one field, several readers, none
1419
+ // agreeing" shape, three ledgers wide. One helper on every ledger read, so the fourth ledger
1420
+ // added later cannot reintroduce it.
1415
1421
  function ledgerFilesFor(w, basename) {
1416
1422
  const out = [];
1417
1423
  const d = stateDirFor(w);
@@ -1435,33 +1441,28 @@ function readLedger(w, basename) {
1435
1441
  }
1436
1442
  function readAcceptances(w) {
1437
1443
  const acc = new Map(); // initiative_id -> { action, at }
1438
- for (const f of acceptanceFilesFor(w)) {
1439
- let text = '';
1440
- try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
1441
- for (const line of text.split('\n')) {
1442
- if (!line.trim()) continue;
1443
- // Tolerate both shapes: this server's {action} and the kit's {accepted} (D54 adapter).
1444
- // THE BOOLEAN OUTRANKS THE LABEL. `action` is an open vocabulary describing what the PM did
1445
- // — this corpus holds accept, merge, publish, return — while `accepted` is the D12 verdict
1446
- // itself. Reading `action` first (the previous behaviour) meant every row labelled `merge` or
1447
- // `publish` carried `accepted: true` and was still not counted: 7 of 16 real acceptances went
1448
- // missing, and the surface reported those initiatives as merely `building`.
1449
- try {
1450
- const r = JSON.parse(line);
1451
- if (!r.initiative_id) continue;
1452
- const label = String(r.action || '').toLowerCase();
1453
- const action = r.accepted === true ? 'accept'
1454
- : r.accepted === false ? 'return'
1455
- : label === 'return' ? 'return'
1456
- : ['accept', 'approve', 'merge', 'publish'].includes(label) ? 'accept'
1457
- : null;
1458
- const at = r.at || r.date || null;
1459
- // Last write wins, but a row with no timestamp never displaces a dated one.
1460
- const prev = acc.get(r.initiative_id);
1461
- if (prev && prev.at && !at) continue;
1462
- acc.set(r.initiative_id, { action, at });
1463
- } catch {}
1464
- }
1444
+ for (const r of readLedger(w, 'acceptances.jsonl')) {
1445
+ // Tolerate both shapes: this server's {action} and the kit's {accepted} (D54 adapter).
1446
+ // THE BOOLEAN OUTRANKS THE LABEL. `action` is an open vocabulary describing what the PM did
1447
+ // — this corpus holds accept, merge, publish, return — while `accepted` is the D12 verdict
1448
+ // itself. Reading `action` first (the previous behaviour) meant every row labelled `merge` or
1449
+ // `publish` carried `accepted: true` and was still not counted: 7 of 16 real acceptances went
1450
+ // missing, and the surface reported those initiatives as merely `building`.
1451
+ if (!r.initiative_id) continue;
1452
+ const label = String(r.action || '').toLowerCase();
1453
+ const action = r.accepted === true ? 'accept'
1454
+ : r.accepted === false ? 'return'
1455
+ : label === 'return' ? 'return'
1456
+ : ['accept', 'approve', 'merge', 'publish'].includes(label) ? 'accept'
1457
+ : null;
1458
+ const at = r.at || r.date || null;
1459
+ // Last write wins, judged by timestamp — the two ledgers arrive in FILE order, not time order,
1460
+ // so a dated earlier row must yield only to a LATER date (ISO strings compare lexicographically;
1461
+ // a bare YYYY-MM-DD loses same-day ties to a full stamp). An undated row never displaces a dated
1462
+ // one; between undated rows, last read wins.
1463
+ const prev = acc.get(r.initiative_id);
1464
+ if (prev && prev.at && (!at || at < prev.at)) continue;
1465
+ acc.set(r.initiative_id, { action, at });
1465
1466
  }
1466
1467
  return acc;
1467
1468
  }
@@ -1499,7 +1500,8 @@ function surfaceInitiatives(root, w) {
1499
1500
  const a = acc.get(g.id) || {};
1500
1501
  const deliveredBy = !del.ok ? null : del.byBranch.has(g.id) ? 'branch' : del.byMention.has(g.id) ? 'mention' : null;
1501
1502
  const { state, why } = deriveState({
1502
- accepted: a.action === 'accept' || a.action === 'approve', acceptedAt: a.at,
1503
+ // readAcceptances already folds every accepting label (approve/merge/publish) into 'accept'.
1504
+ accepted: a.action === 'accept', acceptedAt: a.at,
1503
1505
  returned: a.action === 'return',
1504
1506
  needs: needsBy.get(g.id) || 0,
1505
1507
  delivery: del.ok ? 'known' : 'unknown',
@@ -1606,15 +1608,16 @@ function surfaceOkr(root) {
1606
1608
  let files = [];
1607
1609
  try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')); } catch { continue; }
1608
1610
  for (const f of files) {
1609
- let head = '';
1610
- try { head = fs.readFileSync(path.join(dir, f), 'utf8').slice(0, 4096); } catch { continue; }
1611
- const m = /^parent_kr:[ \t]*(.*)$/m.exec(head);
1612
- if (!m) continue;
1613
- const r = A.resolveAnchor(m[1], index, budgets);
1611
+ // The LEADING frontmatter block only — body text must never be able to fake a declaration —
1612
+ // and the same reader surfaceSpine uses, so the two panels cannot disagree on one record.
1613
+ // A present-but-empty `parent_kr:` still reaches resolveAnchor and counts as `empty`.
1614
+ const fm = leadingFrontmatter(path.join(dir, f));
1615
+ if (fm.parent_kr === undefined) continue;
1616
+ const r = A.resolveAnchor(fm.parent_kr, index, budgets);
1614
1617
  if (r.kind === 'empty') { empty++; continue; }
1615
1618
  checked++;
1616
1619
  if (r.status === 'unresolvable' || r.status === 'unknown') {
1617
- unresolvable.push({ initiative: f.replace(/\.md$/, ''), raw: String(m[1]).trim().slice(0, 80), reason: r.reason, status: r.status });
1620
+ unresolvable.push({ initiative: f.replace(/\.md$/, ''), raw: String(fm.parent_kr).trim().slice(0, 80), reason: r.reason, status: r.status });
1618
1621
  }
1619
1622
  }
1620
1623
  }
@@ -1818,7 +1821,10 @@ function reportToMarkdown(surface) {
1818
1821
  function fireSchedule(sched) {
1819
1822
  const w = workspaces.get(sched.ws) || homeWorkspace();
1820
1823
  const root = w ? w.root : SELF_ROOT;
1821
- const task = taskById(sched.task) || taskById(sched.skill);
1824
+ // One corpus load probing both keys — taskById would re-read every .skill file per probe.
1825
+ const tasks = loadTasks();
1826
+ const byId = (id) => tasks.find((t) => t.id === id || t.skill === id) || null;
1827
+ const task = byId(sched.task) || byId(sched.skill);
1822
1828
  // AGENT task — fire as a real PTY session running its skill (async; the skill files its own
1823
1829
  // deliverable). The workspace starts it + stamps 'started'; run-now and the tick share this path.
1824
1830
  if (task && task.kind === 'agent') {
@@ -2049,7 +2055,9 @@ function broadcast(obj) {
2049
2055
  }
2050
2056
  // The agent process behind a session. tmux spawns the command directly (see ensureTmuxSession's
2051
2057
  // `-- cmd`, no shell), so pane_pid IS the agent — one hop, no guessing. Cached because a pid
2052
- // cannot change for the life of a session.
2058
+ // cannot change for the life of a session — but a FAILED lookup is not a pid: tmux can be
2059
+ // transiently busy, and caching its null would disable the harness drill-in until restart. The
2060
+ // retry costs one shell-out per transcript re-resolution, already throttled to ~2s by txFoundAt.
2053
2061
  function agentPid(s) {
2054
2062
  if (s.agentPid !== undefined) return s.agentPid;
2055
2063
  let pid = null;
@@ -2060,7 +2068,7 @@ function agentPid(s) {
2060
2068
  } else if (s.pty && s.pty.pid) {
2061
2069
  pid = s.pty.pid;
2062
2070
  }
2063
- s.agentPid = pid;
2071
+ if (pid != null) s.agentPid = pid;
2064
2072
  return pid;
2065
2073
  }
2066
2074
 
@@ -2557,14 +2565,6 @@ function exitedSession(base, workdir, why) {
2557
2565
  sessions.set(s.id, s); persist(s); pushSessions(); pushWorkspaces(); return s;
2558
2566
  }
2559
2567
 
2560
- // Live (non-exited) sessions belonging to a workspace. Used to decide whether isolating a branch
2561
- // would actually protect anyone, and to refuse removing a worktree somebody is working in.
2562
- function liveSessionCount(wsIdent) {
2563
- let n = 0;
2564
- for (const s of sessions.values()) if (s.ws === wsIdent && s.state !== 'exited') n++;
2565
- return n;
2566
- }
2567
-
2568
2568
  // The next unused session id. `seq` is a per-process counter, so it can only be trusted once it
2569
2569
  // has been floored by what is already on disk and in memory (see restorePersisted) — this loop is
2570
2570
  // the last guard: a fresh session must never take an id another session's record already owns,
@@ -2737,12 +2737,14 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
2737
2737
  if (!pres.ok) return { ok: false, error: pres.error };
2738
2738
  let targetWs = w.id;
2739
2739
  let notice = null;
2740
- // Isolation exists to protect OTHER sessions. Alone in a workspace there are none to protect, so
2741
- // a plain checkout costs nothing and leaves no folder behind — which is the difference between
2742
- // "a worktree when it earns its keep" and a directory per branch forever. The client sends what
2743
- // the PM actually saw; this default only decides for a caller that expressed no preference (an
2744
- // older client, a scheduled run).
2745
- if (isolate === undefined || isolate === null) isolate = liveSessionCount(w.id) > 0;
2740
+ // Isolation exists to protect OTHER sessions. Alone in the working TREE there are none to
2741
+ // protect, so a plain checkout costs nothing and leaves no folder behind — which is the
2742
+ // difference between "a worktree when it earns its keep" and a directory per branch forever.
2743
+ // Counted by tree, not workspace id, for the reason worktreeReport states: a session in a
2744
+ // workspace registered at a SUBDIRECTORY of this tree is one this checkout would switch branches
2745
+ // under — two ids, one checkout. The client sends what the PM actually saw; this default only
2746
+ // decides for a caller that expressed no preference (an older client, a scheduled run).
2747
+ if (isolate === undefined || isolate === null) isolate = treeTenants(treeForWs(w.id), null).length > 0;
2746
2748
 
2747
2749
  if (branch) {
2748
2750
  if (!gitInfo.isRepo(w.root)) {
@@ -3165,8 +3167,10 @@ function needsResult(w) {
3165
3167
  question: n.question || n.t || '(unstated)', derived: !!n.derived, doc: n.doc || null,
3166
3168
  deliverable: n.deliverable || null,
3167
3169
  }));
3168
- if (!items.length) return { ok: true, empty: true };
3169
- return { ok: true, empty: false, data: { items } };
3170
+ // `all` is the RAW combined queue — what readNeeds(w) computes — carried so /api/needs can serve
3171
+ // both the strict state and the queue from one derivation instead of deriving twice per request.
3172
+ if (!items.length) return { ok: true, empty: true, all };
3173
+ return { ok: true, empty: false, data: { items }, all };
3170
3174
  }
3171
3175
  // R12 — the per-tree aggregate the collapsed sidebar rows need, and R10's honesty rule applied to
3172
3176
  // the count itself. It deliberately routes through needsResult(), the STRICT reader: readNeeds()
@@ -4223,11 +4227,15 @@ async function handleRequest(req, res) {
4223
4227
  if (p === '/api/schedules' && req.method === 'GET') {
4224
4228
  const wsFilter = getWs(url.searchParams.get('ws'));
4225
4229
  const id = wsFilter ? wsFilter.id : null;
4230
+ // One skills-corpus read per request, not one per schedule row — taskById re-reads every
4231
+ // .skill file, and the PM's forty-five schedules made that forty-five reads per GET.
4232
+ const tasks = loadTasks();
4233
+ const taskOf = (tid) => tasks.find((t) => t.id === tid || t.skill === tid) || null;
4226
4234
  const list = readSchedules().filter((s) => !id || s.ws === id).map((s) => {
4227
4235
  // `profile_applies` is the difference between "no profile was chosen" and "a profile could
4228
4236
  // not apply here at all": a skill-kind task runs IN THIS PROCESS and never creates a PTY, so
4229
4237
  // the detail row states that instead of offering a control that would do nothing (R8.3).
4230
- const t = taskById(s.task || s.skill);
4238
+ const t = taskOf(s.task || s.skill);
4231
4239
  return { ...s, next: scheduleLogic.nextRun(s),
4232
4240
  profile: s.profile == null ? null : s.profile,
4233
4241
  profile_applies: !!(t && t.kind === 'agent') };
@@ -4413,8 +4421,18 @@ async function handleRequest(req, res) {
4413
4421
  if (p === '/api/needs') {
4414
4422
  const w = getWs(url.searchParams.get('ws'));
4415
4423
  const strict = needsResult(w);
4424
+ // One derivation per request, not three: the strict read already holds the combined queue
4425
+ // (`all` — identical to readNeeds when the read succeeds; on a corrupt file readNeeds keeps its
4426
+ // lenient derived-only fallback), and the by_ws cache is seeded from the same read so the
4427
+ // selected tree is not derived again — fresher than any cache hit, so R10 still holds.
4428
+ if (w) {
4429
+ let sig; try { sig = needsSig(w); } catch { sig = 'unreadable'; }
4430
+ needsAgg.set(w.id, { at: Date.now(), sig, value: strict.ok
4431
+ ? { count: strict.empty ? 0 : strict.data.items.length, error: null }
4432
+ : { count: null, error: strict.error } });
4433
+ }
4416
4434
  return json(res, {
4417
- needs: readNeeds(w),
4435
+ needs: strict.ok ? strict.all : readNeeds(w),
4418
4436
  state: strict.ok ? { ok: true } : { ok: false, error: strict.error },
4419
4437
  by_ws: needsByWorkspace(),
4420
4438
  });
@@ -4804,14 +4822,21 @@ async function handleRequest(req, res) {
4804
4822
  let changedText = '';
4805
4823
  try { changedText = fs.readFileSync(full, 'utf8'); } catch {}
4806
4824
  const docs = [];
4825
+ let oversized = 0;
4807
4826
  for (const f of markdownDocs(w.root)) {
4808
4827
  if (f === rel) continue;
4809
4828
  const fp = path.resolve(w.root, f);
4810
4829
  if (!insideRoot(w.root, fp)) continue;
4811
- try { docs.push({ path: f, text: fs.readFileSync(fp, 'utf8') }); } catch {}
4830
+ // Same per-file bound as the search corpus: a pathological multi-MB document is skipped and
4831
+ // COUNTED (the honesty contract — `considered` must not silently shrink), never read whole
4832
+ // onto the thread that carries every PTY byte.
4833
+ try {
4834
+ if (fs.statSync(fp).size > SEARCH_CONTENT_BYTES) { oversized++; continue; }
4835
+ docs.push({ path: f, text: fs.readFileSync(fp, 'utf8') });
4836
+ } catch {}
4812
4837
  }
4813
4838
  const cands = relatedDocs.candidates({ changedPath: rel, changedText, removed: last.before, docs });
4814
- return json(res, { ok: true, change: last, candidates: cands.slice(0, 12), considered: docs.length, total: cands.length });
4839
+ return json(res, { ok: true, change: last, candidates: cands.slice(0, 12), considered: docs.length, skipped_oversized: oversized, total: cands.length });
4815
4840
  }
4816
4841
  if (p === '/api/align/start' && req.method === 'POST') {
4817
4842
  // Hand the change and its candidates to an agent, which DRAFTS the realigning edits (R5). The
@@ -27,7 +27,7 @@
27
27
  const fs = require('fs');
28
28
  const os = require('os');
29
29
  const path = require('path');
30
- const { execFileSync } = require('child_process');
30
+ const { execFileSync, spawnSync } = require('child_process');
31
31
 
32
32
  const HOST = '127.0.0.1';
33
33
 
@@ -42,6 +42,28 @@ function claimEphemeralPort() {
42
42
  return n;
43
43
  }
44
44
 
45
+ /**
46
+ * Poll a child server until it answers 200, instead of sleeping a fixed interval. A fixed sleep
47
+ * that expires early is a suite that aborts on its first request — observed as a lost race on a
48
+ * loaded machine (the first page.goto got ECONNREFUSED and the run stopped partway), and an
49
+ * aborted run's count is not a coverage claim. Booting is slower than a plain HTTP server:
50
+ * node-pty loads and sessions are restored before the port is bound. server.js does all of that
51
+ * (including the first-run shell spawn) BEFORE it listens, so a 200 means the server is fully up.
52
+ *
53
+ * `probe` must be a path every child serves regardless of workspace shape; `/pm/tokens.css` is
54
+ * routed unconditionally. The poll is read-only, so it changes no server state (the F-restart
55
+ * block in test.js relies on that: no tmux reset between its two servers).
56
+ */
57
+ async function waitForServer(base, { deadline = 30000, probe = '/pm/tokens.css', step = 150 } = {}) {
58
+ const t0 = Date.now();
59
+ for (;;) {
60
+ const r = await fetch(base + probe).catch(() => null);
61
+ if (r && r.status === 200) return;
62
+ if (Date.now() - t0 > deadline) throw new Error(`server did not start on ${base} within ${Math.round(deadline / 1000)}s`);
63
+ await new Promise((res) => setTimeout(res, step));
64
+ }
65
+ }
66
+
45
67
  /**
46
68
  * Arm a suite. MUST be called before `require('./server')` and before any child is spawned —
47
69
  * server.js resolves its state root at require time, and a child inherits the environment as it
@@ -51,10 +73,23 @@ function claimEphemeralPort() {
51
73
  */
52
74
  function configure({ name = 'run', port = null } = {}) {
53
75
  if (state) return state;
54
- const stateDir = process.env.PMOS_STATE_DIR
55
- ? path.resolve(process.env.PMOS_STATE_DIR)
56
- : fs.mkdtempSync(path.join(os.tmpdir(), `pmos-state-${name}-`));
57
- fs.mkdirSync(stateDir, { recursive: true });
76
+ // DELETE the ambient values rather than adopt them. PMOS_STATE_DIR, PMOS_WS_PORT and
77
+ // PMOS_TMUX_SOCK are the PM's documented REAL-workspace overrides (workspace/README.md,
78
+ // bin/pmos.js, lib/state-root.js even advises exporting PMOS_STATE_DIR to resolve a state
79
+ // conflict). Adopting them here pointed the suite at live state: resetState() then rm -rf'd the
80
+ // PM's own sessions/registry, an exported port made the boot poll answer 200 from the PM's REAL
81
+ // server and drive it, and killTmux() killed their daemon. Same fix as PMOS_SELF_ROOT in
82
+ // test.js: the only value meaning "this run's own" is the one minted here.
83
+ delete process.env.PMOS_STATE_DIR;
84
+ delete process.env.PMOS_WS_PORT;
85
+ delete process.env.PMOS_TMUX_SOCK;
86
+ const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), `pmos-state-${name}-`));
87
+ // Remove the scratch tree when the suite exits, green or red — nothing did, and every run left
88
+ // a pmos-state-* directory (scrollback, registry JSON) in tmpdir. Registered beside the mkdtemp
89
+ // above so it can only ever remove a directory THIS run made; removal at process exit only, so
90
+ // state survives between child servers within a run (the F-restart block needs that). `exit`
91
+ // handlers are synchronous-only — rmSync qualifies. A SIGKILLed suite still leaks, as before.
92
+ process.on('exit', () => { try { fs.rmSync(stateDir, { recursive: true, force: true }); } catch {} });
58
93
  // In the environment BEFORE server.js is required here and before any child inherits it. Every
59
94
  // boot site in every suite goes through process.env, so this is a chokepoint by construction
60
95
  // rather than a convention each call site has to remember — and a convention that must be
@@ -62,7 +97,8 @@ function configure({ name = 'run', port = null } = {}) {
62
97
  // which is exactly how this class of defect got in.
63
98
  process.env.PMOS_STATE_DIR = stateDir;
64
99
  // A private tmux socket, never the PM's own ('pmos'), so a suite cannot reach their sessions.
65
- process.env.PMOS_TMUX_SOCK = process.env.PMOS_TMUX_SOCK || `pmos-${name}-${process.pid}`;
100
+ const tmuxSock = `pmos-${name}-${process.pid}`;
101
+ process.env.PMOS_TMUX_SOCK = tmuxSock;
66
102
 
67
103
  const chosen = Number(port) > 0 ? Number(port) : claimEphemeralPort();
68
104
 
@@ -74,11 +110,17 @@ function configure({ name = 'run', port = null } = {}) {
74
110
  },
75
111
  /** A port nobody else holds, for a suite that needs a second server. */
76
112
  port: claimEphemeralPort,
77
- /** Remove the scratch state directory. Safe by construction: this run made it. */
78
- cleanup() { try { fs.rmSync(stateDir, { recursive: true, force: true }); } catch {} },
113
+ /** Poll a spawned server until it answers — see waitForServer above. */
114
+ waitForServer,
115
+ /** Kill the tmux daemon this run minted, with every pane on it. Safe by construction: the
116
+ * socket is private and per-pid, so this can never reach the PM's own ('pmos'). Every suite
117
+ * that spawns the server must call this on its way out — SIGKILLing the server child leaves
118
+ * the daemon and its shells running indefinitely, and per-pid sockets accumulate without
119
+ * bound. */
120
+ killTmux() { try { spawnSync('tmux', ['-L', tmuxSock, 'kill-server'], { stdio: 'ignore' }); } catch {} },
79
121
  };
80
122
  Object.assign(module.exports, state);
81
123
  return state;
82
124
  }
83
125
 
84
- module.exports = { configure, claimEphemeralPort, HOST };
126
+ module.exports = { configure, claimEphemeralPort, waitForServer, HOST };