create-pmos 0.5.0 → 0.6.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 (54) hide show
  1. package/README.md +114 -0
  2. package/bin/create-pmos.js +93 -7
  3. package/package.json +7 -3
  4. package/payload/AGENTS-HUMAN.md +6 -0
  5. package/payload/AGENTS.md +35 -1
  6. package/payload/KIT-VERSION +2 -2
  7. package/payload/README.md +6 -0
  8. package/payload/SECURITY.md +6 -0
  9. package/payload/okf/core/concepts/frontmatter-format.md +75 -0
  10. package/payload/okf/core/concepts/index.md +3 -0
  11. package/payload/okf/core/concepts/log.md +13 -0
  12. package/payload/okf/core/templates/eval-rubric-template.md +12 -10
  13. package/payload/okf/core/templates/prd-template.md +6 -0
  14. package/payload/okf/core/templates/prototype-brief-template.md +45 -21
  15. package/payload/planning/evals/agent-run-eval-rubric.md +10 -7
  16. package/payload/scripts/metrics.py +142 -121
  17. package/payload/skills/SKILL-FORMAT.md +6 -0
  18. package/payload/skills/discovery.skill +4 -0
  19. package/payload/skills/eval-rubric.skill +1 -1
  20. package/payload/skills/explain-surface.skill +156 -0
  21. package/payload/skills/pmos-router.skill +25 -3
  22. package/payload/skills/prd.skill +8 -0
  23. package/payload/skills/prototype.skill +106 -41
  24. package/payload/skills-native/discovery/SKILL.md +4 -0
  25. package/payload/skills-native/eval-rubric/SKILL.md +1 -1
  26. package/payload/skills-native/explain-surface/SKILL.md +158 -0
  27. package/payload/skills-native/pmos-router/SKILL.md +25 -3
  28. package/payload/skills-native/prd/SKILL.md +8 -0
  29. package/payload/skills-native/prototype/SKILL.md +106 -41
  30. package/payload/templates/agent-run-eval-rubric.md +10 -7
  31. package/payload/templates/ticket-template.md +2 -0
  32. package/workspace/README.md +112 -0
  33. package/workspace/REMOTE.md +131 -0
  34. package/workspace/bin/pmos.js +206 -0
  35. package/workspace/doctor.js +99 -0
  36. package/workspace/lib/console-state.js +117 -0
  37. package/workspace/lib/file-mode-parse.js +58 -0
  38. package/workspace/lib/harness-parse.js +222 -0
  39. package/workspace/lib/preview-kind.js +98 -0
  40. package/workspace/lib/rate-limit.js +26 -0
  41. package/workspace/lib/remote-access.js +235 -0
  42. package/workspace/lib/review-gate.js +39 -0
  43. package/workspace/lib/schedule-logic.js +132 -0
  44. package/workspace/lib/skill-store.js +143 -0
  45. package/workspace/lib/surface-contract.js +79 -0
  46. package/workspace/lib/transcript-read.js +247 -0
  47. package/workspace/package-lock.json +94 -0
  48. package/workspace/package.json +23 -0
  49. package/workspace/public/app.js +1710 -0
  50. package/workspace/public/index.html +175 -0
  51. package/workspace/public/styles.css +766 -0
  52. package/workspace/server.js +1753 -0
  53. package/workspace/skills/backlog-triage.skill +13 -0
  54. package/workspace/skills/weekly-report.skill +19 -0
@@ -0,0 +1,132 @@
1
+ 'use strict';
2
+ /* schedule-logic.js — the pure cadence math for scheduled tasks.
3
+ *
4
+ * Harvested from pm-os `do-worker/src/schedule-logic.mjs`: no I/O, no globals, importable by the
5
+ * server tick AND (conceptually) the GUI's "next run" preview — so what the PM is shown is exactly
6
+ * what will fire. Window-based due-ness, NO catch-up (a missed slot is skipped, per the PRD): a
7
+ * schedule is due iff a scheduled slot falls in the interval (lastRun, now].
8
+ *
9
+ * A schedule: { cadence:'daily'|'weekly'|'bi-weekly'|'monthly', day:'Mon'..'Sun' (weekly/bi-weekly),
10
+ * dayOfMonth:1..28 (monthly), time:'HH:MM', enabled, lastRun:ISO|null }. Times are host-local (v1).
11
+ */
12
+
13
+ const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; // JS getDay() index
14
+ const CADENCES = ['daily', 'weekly', 'bi-weekly', 'monthly'];
15
+
16
+ function parseTime(t) {
17
+ const m = /^(\d{1,2}):(\d{2})$/.exec(String(t || '09:00'));
18
+ if (!m) return { h: 9, m: 0 };
19
+ return { h: Math.min(23, +m[1]), m: Math.min(59, +m[2]) };
20
+ }
21
+ function atTime(d, time) {
22
+ const { h, m } = parseTime(time);
23
+ const x = new Date(d);
24
+ x.setHours(h, m, 0, 0);
25
+ return x;
26
+ }
27
+ function dayIndex(day) { const i = DAYS.indexOf(String(day)); return i < 0 ? 1 : i; } // default Mon
28
+
29
+ // The most recent scheduled slot at or before `now` (Date), or null if none exists yet.
30
+ function lastSlot(schedule, now) {
31
+ const cad = schedule.cadence;
32
+ if (cad === 'daily') {
33
+ const today = atTime(now, schedule.time);
34
+ return today <= now ? today : addDays(today, -1);
35
+ }
36
+ if (cad === 'weekly' || cad === 'bi-weekly') {
37
+ // walk back from today to the most recent matching weekday at time
38
+ const want = dayIndex(schedule.day);
39
+ let d = atTime(now, schedule.time);
40
+ for (let i = 0; i < 8; i++) {
41
+ if (d.getDay() === want && d <= now) return d;
42
+ d = addDays(d, -1);
43
+ }
44
+ return null;
45
+ }
46
+ if (cad === 'monthly') {
47
+ const dom = Math.min(28, Math.max(1, +schedule.dayOfMonth || 1));
48
+ let d = atTime(now, schedule.time); d.setDate(dom);
49
+ if (d > now) { d = new Date(d); d.setMonth(d.getMonth() - 1); d.setDate(dom); }
50
+ return d;
51
+ }
52
+ return null;
53
+ }
54
+ // The next scheduled slot strictly after `now`.
55
+ function nextSlot(schedule, now) {
56
+ const cad = schedule.cadence;
57
+ if (cad === 'daily') {
58
+ const today = atTime(now, schedule.time);
59
+ return today > now ? today : addDays(today, 1);
60
+ }
61
+ if (cad === 'weekly' || cad === 'bi-weekly') {
62
+ const want = dayIndex(schedule.day);
63
+ let d = atTime(now, schedule.time);
64
+ for (let i = 0; i < 8; i++) {
65
+ if (d.getDay() === want && d > now) return d;
66
+ d = addDays(d, 1);
67
+ }
68
+ return null;
69
+ }
70
+ if (cad === 'monthly') {
71
+ const dom = Math.min(28, Math.max(1, +schedule.dayOfMonth || 1));
72
+ let d = atTime(now, schedule.time); d.setDate(dom);
73
+ if (d <= now) { d = new Date(d); d.setMonth(d.getMonth() + 1); d.setDate(dom); }
74
+ return d;
75
+ }
76
+ return null;
77
+ }
78
+ function addDays(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; }
79
+
80
+ // Is the schedule due to fire at `now`? Enabled + a slot in (lastRun, now]. For bi-weekly, also
81
+ // require ≥ 13 days since lastRun (weekly slots, fired every other week).
82
+ function isDue(schedule, now = new Date()) {
83
+ if (!schedule || !schedule.enabled) return false;
84
+ const slot = lastSlot(schedule, now);
85
+ if (!slot) return false;
86
+ const last = schedule.lastRun ? new Date(schedule.lastRun) : null;
87
+ if (last && last >= slot) return false;
88
+ if (schedule.cadence === 'bi-weekly' && last) {
89
+ if ((now - last) / 86400000 < 13) return false;
90
+ }
91
+ return true;
92
+ }
93
+ // The next fire time as an ISO string (or null).
94
+ function nextRun(schedule, now = new Date()) {
95
+ const s = nextSlot(schedule, now);
96
+ return s ? s.toISOString() : null;
97
+ }
98
+ // A short human phrase for the pane, e.g. "every Fri at 09:00".
99
+ function humanCadence(schedule) {
100
+ const t = schedule.time || '09:00';
101
+ switch (schedule.cadence) {
102
+ case 'daily': return `every day at ${t}`;
103
+ case 'weekly': return `every ${schedule.day || 'Mon'} at ${t}`;
104
+ case 'bi-weekly': return `every other ${schedule.day || 'Mon'} at ${t}`;
105
+ case 'monthly': return `monthly on day ${schedule.dayOfMonth || 1} at ${t}`;
106
+ default: return schedule.cadence || '';
107
+ }
108
+ }
109
+ // Validate/normalize a schedule submitted by the client. Returns { ok, schedule } or { ok:false, error }.
110
+ function normalizeSchedule(input) {
111
+ const s = input || {};
112
+ if (!s.name || !String(s.name).trim()) return { ok: false, error: 'name is required' };
113
+ if (!CADENCES.includes(s.cadence)) return { ok: false, error: 'invalid cadence' };
114
+ if (!s.skill || !String(s.skill).trim()) return { ok: false, error: 'a skill-backed task is required' };
115
+ return {
116
+ ok: true,
117
+ schedule: {
118
+ name: String(s.name).trim().slice(0, 80),
119
+ cadence: s.cadence,
120
+ day: s.day || 'Mon',
121
+ dayOfMonth: Math.min(28, Math.max(1, +s.dayOfMonth || 1)),
122
+ time: parseTimeStr(s.time),
123
+ tz: s.tz || 'local',
124
+ task: s.task || s.skill,
125
+ skill: String(s.skill).trim(),
126
+ enabled: s.enabled !== false,
127
+ },
128
+ };
129
+ }
130
+ function parseTimeStr(t) { const { h, m } = parseTime(t); return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; }
131
+
132
+ module.exports = { CADENCES, isDue, nextRun, humanCadence, normalizeSchedule, lastSlot, nextSlot };
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+ /* skill-store.js — file-driven skills. ONE user-facing concept.
3
+ *
4
+ * A "skill" is a named, repeatable task with a consistent-output contract. HOW it runs — the server
5
+ * derives it instantly, or a `claude` PTY session reasons through it — is an INTERNAL field (`run:`)
6
+ * the picker never exposes. That collapses the old deterministic-vs-agent "kind" leak the PM (rightly)
7
+ * found confusing (workspace-skill-authoring): from the user's view there is just a skill.
8
+ *
9
+ * A skill file (`*.skill`) is frontmatter + a body procedure:
10
+ * - frontmatter: name, description, and an `output` list = the section keys it ALWAYS produces
11
+ * (the consistency contract, mirroring the surface-contract shape check).
12
+ * - body: the procedure. For an agent skill this is the prompt fed to the session; for a built-in
13
+ * deterministic skill the body is documentation (the server derives the output from `surface`).
14
+ *
15
+ * Built-in skills ship committed under workspace/skills/; user-authored skills are scaffolded by the
16
+ * New Skill flow into a writable dir (state/skills/, gitignored like other state). Pure of the network
17
+ * (D54) — fs only, no deps, unit-testable directly.
18
+ */
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+
22
+ // A skill name is a short slug — it is both the id and the filename stem, so it must be path-safe.
23
+ const NAME_RE = /^[a-z0-9][a-z0-9-]{0,48}$/;
24
+ const MAX_OUTPUT = 20;
25
+
26
+ function slugify(s) {
27
+ return String(s || '').toLowerCase().trim()
28
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48);
29
+ }
30
+ function titleCase(s) {
31
+ return String(s || '').replace(/[-_]+/g, ' ').replace(/\s+/g, ' ').trim()
32
+ .replace(/\b\w/g, (c) => c.toUpperCase());
33
+ }
34
+ function stripQuotes(v) { return String(v).replace(/^["']|["']$/g, ''); }
35
+
36
+ // A permissive mini-parser: an optional leading `---` frontmatter block (scalar `key: value` and
37
+ // one-level `- ` lists), then the body. No YAML dependency — the format is deliberately tiny and
38
+ // self-explanatory (the ACI design principle, D19). Unknown keys are tolerated.
39
+ function parseSkill(text) {
40
+ const src = String(text || '');
41
+ const fm = {}; const lists = {};
42
+ let body = src.trim();
43
+ const lines = src.split('\n');
44
+ if (lines[0] && lines[0].trim() === '---') {
45
+ let curList = null; let closed = false; let bodyStart = 1;
46
+ for (let i = 1; i < lines.length; i++) {
47
+ const line = lines[i];
48
+ if (line.trim() === '---') { bodyStart = i + 1; closed = true; break; }
49
+ const item = line.match(/^\s*-\s+(.*)$/);
50
+ if (item && curList) { lists[curList].push(item[1].trim()); continue; }
51
+ const kv = line.match(/^([A-Za-z_][\w-]*):\s*(.*)$/);
52
+ if (kv) {
53
+ const key = kv[1]; const val = kv[2].trim();
54
+ if (val === '') { curList = key; lists[key] = lists[key] || []; }
55
+ else { fm[key] = stripQuotes(val); curList = null; }
56
+ }
57
+ }
58
+ if (closed) body = lines.slice(bodyStart).join('\n').trim();
59
+ }
60
+ const name = fm.name || '';
61
+ // `run` is the ONLY internal signal: deterministic (server fast-path) vs agent (a PTY session).
62
+ const run = /^det/i.test(fm.run || '') ? 'deterministic' : 'agent';
63
+ return {
64
+ name,
65
+ label: fm.label || titleCase(name),
66
+ description: fm.description || '',
67
+ run,
68
+ surface: fm.surface || null,
69
+ agent: fm.agent || 'claude',
70
+ output: (lists.output || []).filter(Boolean).slice(0, MAX_OUTPUT),
71
+ body,
72
+ };
73
+ }
74
+
75
+ function loadDir(dir, source) {
76
+ const out = [];
77
+ let ents = [];
78
+ try { ents = fs.readdirSync(dir); } catch { return out; }
79
+ for (const f of ents.sort()) {
80
+ if (!f.endsWith('.skill')) continue;
81
+ let text = '';
82
+ try { text = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; }
83
+ const s = parseSkill(text);
84
+ const id = s.name || f.replace(/\.skill$/, '');
85
+ if (!NAME_RE.test(id)) continue; // an unparseable/hostile filename never becomes a skill
86
+ out.push({ ...s, name: id, id, skill: id, source, file: path.join(dir, f) });
87
+ }
88
+ return out;
89
+ }
90
+
91
+ // Merge a list of {dir, source} — LATER dirs override earlier by id. Callers pass user dirs first and
92
+ // built-ins last, so a built-in's load-bearing fast-path can never be shadowed by a user file.
93
+ function loadSkills(dirs) {
94
+ const byId = new Map();
95
+ for (const { dir, source } of dirs) for (const s of loadDir(dir, source)) byId.set(s.id, s);
96
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
97
+ }
98
+
99
+ // Scaffold a well-formed user skill file from the New Skill flow's fields. Returns { ok, slug, content }
100
+ // or { ok:false, error }. User skills default to run:agent — a user-authored skill reasons through a
101
+ // claude session (the existing agent path); the deterministic fast-path is reserved for the built-ins.
102
+ function scaffoldSkill({ name, description, output, body } = {}) {
103
+ const slug = slugify(name);
104
+ if (!NAME_RE.test(slug)) return { ok: false, error: 'name must be a short slug (letters, digits, hyphens)' };
105
+ const label = titleCase(name) || slug;
106
+ const desc = String(description || '').replace(/[\r\n]+/g, ' ').trim().slice(0, 240);
107
+ let out = Array.isArray(output) ? output : String(output || '').split(/[\n,]/);
108
+ out = out.map((x) => String(x).replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, MAX_OUTPUT);
109
+ if (!out.length) out = ['Summary', 'Findings', 'Next steps']; // always a valid contract
110
+ const proc = String(body || '').trim() || defaultBody(label, desc, out);
111
+ const content = [
112
+ '---',
113
+ `name: ${slug}`,
114
+ `label: ${JSON.stringify(label)}`,
115
+ `description: ${JSON.stringify(desc)}`,
116
+ 'run: agent',
117
+ 'agent: claude',
118
+ 'output:',
119
+ ...out.map((o) => ` - ${o}`),
120
+ '---',
121
+ '',
122
+ proc,
123
+ '',
124
+ ].join('\n');
125
+ return { ok: true, slug, label, description: desc, output: out, content };
126
+ }
127
+
128
+ function defaultBody(label, desc, out) {
129
+ return [
130
+ `You are running the "${label}" skill in this PMOS workspace (local, file mode).`,
131
+ '',
132
+ `Goal: ${desc || '(describe what this skill should do)'}`,
133
+ '',
134
+ 'Produce a deliverable with EXACTLY these sections — the consistency contract, the same shape',
135
+ 'every run:',
136
+ ...out.map((o) => ` - ${o}`),
137
+ '',
138
+ 'Ground everything in this repository\'s own local files and tools only (no network). When you are',
139
+ 'done, write the deliverable and file a short note to needs-you so the PM sees the result.',
140
+ ].join('\n');
141
+ }
142
+
143
+ module.exports = { NAME_RE, slugify, titleCase, parseSkill, loadDir, loadSkills, scaffoldSkill };
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+ /* surface-contract.js — the deliverable contract for on-demand workspace surfaces.
3
+ *
4
+ * Harvested from pm-os `do-worker/src/deliverable-contract.mjs` (the pm-os intake, Cluster J / J1):
5
+ * each surface TYPE declares the shape it must carry, and a deterministic check validates a surface
6
+ * BEFORE it renders — so "reliable when the PM needs it" is ENFORCED, not hoped. Pure: no I/O, no
7
+ * DOM, no deps, importable by the server and unit-tested directly.
8
+ *
9
+ * A required item FIELD may be null — that means "not tracked in state yet" (RICE/effort/…), and the
10
+ * renderer shows "— not tracked" for it. The contract only insists the KEY exists, so the surface can
11
+ * never silently drop a field; it can only honestly say it doesn't have the value. The `min` on the
12
+ * primary collection is the "can't-produce" floor: a surface with nothing to show fails the contract
13
+ * and the client renders the explicit can't-produce state instead of a blank or partial view.
14
+ */
15
+
16
+ const CONTRACTS = Object.freeze({
17
+ initiatives: {
18
+ label: 'Initiative list',
19
+ top: ['initiatives', 'pulse', 'needs'],
20
+ item: { key: 'initiatives', min: 1, fields: ['id', 'status', 'phase'] },
21
+ },
22
+ roadmap: {
23
+ label: 'Roadmap',
24
+ top: ['groups'],
25
+ item: { key: 'groups', min: 1, fields: ['label', 'items'] },
26
+ },
27
+ okr: {
28
+ label: 'OKR + levers',
29
+ top: ['objectives'],
30
+ item: { key: 'objectives', min: 1, fields: ['id', 'krs'] },
31
+ },
32
+ report: {
33
+ label: 'Report',
34
+ top: ['cadence', 'sections'],
35
+ item: { key: 'sections', min: 3, fields: ['heading'] },
36
+ },
37
+ });
38
+
39
+ function checkSurface(type, data) {
40
+ const c = CONTRACTS[type];
41
+ if (!c) {
42
+ return { pass: false, missing: ['contract'],
43
+ checks: [{ name: 'known-type', pass: false, reason: `no contract for surface "${type}"` }] };
44
+ }
45
+ data = data || {};
46
+ const checks = [];
47
+ const missing = [];
48
+
49
+ // 1) required top-level keys are present
50
+ for (const k of c.top) {
51
+ const present = data[k] !== undefined && data[k] !== null;
52
+ checks.push({ name: `has:${k}`, pass: present, reason: present ? 'present' : 'absent' });
53
+ if (!present) missing.push(k);
54
+ }
55
+
56
+ // 2) the primary collection carries at least `min` items (the can't-produce floor)
57
+ const it = c.item;
58
+ const arr = Array.isArray(data[it.key]) ? data[it.key] : [];
59
+ const enough = arr.length >= it.min;
60
+ checks.push({ name: `min:${it.key}`, pass: enough, reason: `${arr.length} / ${it.min} required` });
61
+ if (!enough) missing.push(it.key);
62
+
63
+ // 3) every item carries its required field KEYS (a null value = "not tracked", still valid)
64
+ let fieldOk = true;
65
+ let offendingField = null;
66
+ for (const item of arr) {
67
+ for (const f of it.fields) {
68
+ if (!item || !Object.prototype.hasOwnProperty.call(item, f)) { fieldOk = false; offendingField = f; break; }
69
+ }
70
+ if (!fieldOk) break;
71
+ }
72
+ checks.push({ name: 'item-fields', pass: fieldOk,
73
+ reason: fieldOk ? `every item carries ${it.fields.join(', ')}` : `an item is missing "${offendingField}"` });
74
+ if (!fieldOk) missing.push('item-fields');
75
+
76
+ return { pass: checks.every((x) => x.pass), missing, checks };
77
+ }
78
+
79
+ module.exports = { CONTRACTS, checkSurface };
@@ -0,0 +1,247 @@
1
+ 'use strict';
2
+ /**
3
+ * transcript-read — the harness, read from the agent CLI's own STRUCTURED record instead of
4
+ * from rendered terminal bytes.
5
+ *
6
+ * WHY THIS EXISTS. The first cut of the harness tower parsed scrollback, and four of six rows
7
+ * in the drill-in came back empty: the CLI does not PRINT its model, effort, token usage or
8
+ * skill set into the terminal. It writes them to a JSONL transcript. So the fix was never a
9
+ * better parser — it was the wrong source. Verified on this machine: 408 assistant records
10
+ * carrying message.model, record-level effort, message.usage token counts, and tool_use names
11
+ * with MCP attribution, plus one skill_listing attachment.
12
+ *
13
+ * ─────────────────────────────────────────────────────────────────────────────────────────
14
+ * VENDOR COUPLING — READ BEFORE CHANGING. Owner: the PM (wawan).
15
+ *
16
+ * This reads an UNDOCUMENTED internal file layout with no compatibility promise:
17
+ * ~/.claude/sessions/<pid>.json → {pid, sessionId, cwd, version, …}
18
+ * ~/.claude/projects/<cwd-slug>/<sessionId>.jsonl
19
+ * The registry file is version-stamped (seen: 2.1.220). When a CLI upgrade moves or reshapes
20
+ * either file, every signal here returns its FAILURE STATE — which is correct behaviour, not a
21
+ * bug to paper over. What to check: (1) does ~/.claude/sessions/<pid>.json still exist and
22
+ * carry sessionId + cwd? (2) is the transcript still <cwd-with-slashes-as-dashes>/<sessionId>.jsonl?
23
+ * (3) do assistant records still carry message.model / message.usage / content[].tool_use?
24
+ *
25
+ * THE RULE THAT MAKES THIS SAFE: no guessing, ever. In particular we NEVER fall back to
26
+ * "the newest .jsonl in this project directory" when the pid→session lookup misses. That
27
+ * fallback would silently render session B's model, tokens and tool counts on session A's row,
28
+ * and two claude sessions in one repo is the NORMAL case here — every session is confined to
29
+ * its workspace root. A miss renders "can't identify this session's transcript" and stops.
30
+ *
31
+ * THE ONE HOP WE DO TOLERATE (child-walk): the workspace's default pane is a SHELL, and the PM
32
+ * types `claude` INSIDE it, so the real agent is a DESCENDANT of the pane pid, which itself has
33
+ * no session record. When — and only when — the pane pid has no record of its own, we descend a
34
+ * BOUNDED number of process levels for a descendant that resolves, accepting it ONLY if its own
35
+ * registry cwd sits inside this workspace root. That is the SAME isolation proof the pane path
36
+ * makes, never a weaker one: identity still flows pid → session record → cwd-in-root. A
37
+ * descendant whose cwd escapes root is refused, not shown. This is emphatically NOT the
38
+ * "newest .jsonl" guess above — we still prove the identity of the pid we resolve.
39
+ *
40
+ * Read-only. Never writes to the PTY or to any CLI state (D62 — the harness runs untouched);
41
+ * the child-walk only reads a `ps` snapshot and session-record files.
42
+ * ─────────────────────────────────────────────────────────────────────────────────────────
43
+ */
44
+ const fs = require('fs');
45
+ const os = require('os');
46
+ const path = require('path');
47
+ const cp = require('child_process');
48
+
49
+ // The child-walk is bounded so a deep shell nest can never stall a broadcast; past this depth the
50
+ // refusal stands (an honest floor, not a silent truncation — the pane pid still had no record).
51
+ const MAX_WALK_DEPTH = 2;
52
+
53
+ const HOME = () => process.env.PMOS_CLAUDE_HOME || path.join(os.homedir(), '.claude');
54
+ // A transcript can grow large; cap what we read so a long session cannot stall a broadcast.
55
+ const MAX_BYTES = 8 * 1024 * 1024;
56
+ // Synthetic assistant records are the CLI's own bookkeeping, not model turns — never counted,
57
+ // and never a source for the model name (they carry the literal '<synthetic>').
58
+ const SYNTHETIC = '<synthetic>';
59
+
60
+ /** cwd → the CLI's project-directory slug. '/home/user/pmos' → '-home-user-pmos'. */
61
+ function slugFor(cwd) {
62
+ return String(cwd || '').replace(/\//g, '-');
63
+ }
64
+
65
+ /**
66
+ * Default descendant lister: ONE `ps` snapshot → a ppid→children map → the direct children of
67
+ * `pid`. Injected in tests so the walk is exercised without real processes. Returns [] on any
68
+ * failure — a resolver miss must degrade to the refusal, never throw.
69
+ */
70
+ function defaultChildren(pid) {
71
+ let out;
72
+ try {
73
+ out = cp.execFileSync('ps', ['-Ao', 'pid=,ppid='], { encoding: 'utf8', timeout: 2000 });
74
+ } catch {
75
+ return [];
76
+ }
77
+ const kids = new Map();
78
+ for (const line of out.split('\n')) {
79
+ const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
80
+ if (!m) continue;
81
+ const p = Number(m[1]);
82
+ const pp = Number(m[2]);
83
+ if (!kids.has(pp)) kids.set(pp, []);
84
+ kids.get(pp).push(p);
85
+ }
86
+ return kids.get(Number(pid)) || [];
87
+ }
88
+
89
+ /** Breadth-first descendants of `pid`, bounded to `maxDepth` levels. `listChildren(pid)->pids`. */
90
+ function descendants(pid, listChildren, maxDepth = MAX_WALK_DEPTH) {
91
+ const seen = new Set([Number(pid)]);
92
+ let frontier = [Number(pid)];
93
+ const found = [];
94
+ for (let d = 0; d < maxDepth && frontier.length; d++) {
95
+ const next = [];
96
+ for (const p of frontier) {
97
+ for (const c of listChildren(p) || []) {
98
+ const cn = Number(c);
99
+ if (seen.has(cn)) continue;
100
+ seen.add(cn);
101
+ next.push(cn);
102
+ found.push(cn);
103
+ }
104
+ }
105
+ frontier = next;
106
+ }
107
+ return found;
108
+ }
109
+
110
+ /**
111
+ * Resolve ONE pid to its validated transcript. Returns {sessionId, file, version} on success, or a
112
+ * typed miss: {noRecord} (no session file for this pid — the shell case), {unreadable}, {outside}
113
+ * (cwd escapes root — the isolation refusal), or {noTranscript} (record exists, transcript not yet
114
+ * written). The cwd-in-root proof here is what makes the child-walk safe: it runs on EVERY pid we
115
+ * consider, pane or descendant, identically.
116
+ */
117
+ function resolvePid(pid, root, home) {
118
+ let reg;
119
+ try {
120
+ reg = JSON.parse(fs.readFileSync(path.join(home, 'sessions', `${pid}.json`), 'utf8'));
121
+ } catch {
122
+ return { noRecord: true };
123
+ }
124
+ if (!reg || !reg.sessionId || !reg.cwd) return { unreadable: true };
125
+ const rel = path.relative(root, reg.cwd);
126
+ if (root && (rel.startsWith('..') || path.isAbsolute(rel))) return { outside: true };
127
+ const file = path.join(home, 'projects', slugFor(reg.cwd), `${reg.sessionId}.jsonl`);
128
+ if (!fs.existsSync(file)) return { noTranscript: true };
129
+ return { sessionId: reg.sessionId, file, version: reg.version || null };
130
+ }
131
+
132
+ /**
133
+ * Map a live process to its transcript. Returns {sessionId, file, version} or {missing}.
134
+ * `root` is the workspace root the session is confined to: the registry's own cwd must sit
135
+ * inside it, or we refuse — that is the isolation check, not a formality. When the pane pid has
136
+ * no record of its own (a SHELL the PM launched claude inside), descend a bounded number of
137
+ * process levels for a descendant that resolves in-root — see the child-walk note at the head.
138
+ * `listChildren` is injectable for tests; it defaults to a real `ps` snapshot.
139
+ */
140
+ function findTranscript(pid, root, home = HOME(), listChildren = defaultChildren) {
141
+ if (!pid) return { missing: "can't identify this session's transcript — no process id" };
142
+ const direct = resolvePid(pid, root, home);
143
+ if (direct.sessionId) return direct;
144
+ // Only a pane with NO record of its own triggers the walk — that is precisely the shell-wrapped
145
+ // case. A pane that HAS a record (outside root, unreadable, or transcript-not-yet-written) is a
146
+ // real, identified session; we return its honest state rather than second-guessing it downward.
147
+ if (direct.noRecord) {
148
+ for (const child of descendants(pid, listChildren)) {
149
+ const r = resolvePid(child, root, home);
150
+ if (r.sessionId) return r; // first in-root descendant with a real transcript wins
151
+ }
152
+ return { missing: "can't identify this session's transcript — the CLI wrote no session record" };
153
+ }
154
+ if (direct.outside) return { missing: "can't identify this session's transcript — it belongs to another workspace" };
155
+ if (direct.unreadable) return { missing: "can't identify this session's transcript — the session record is unreadable" };
156
+ return { missing: "can't read the harness — the CLI has written no transcript yet" };
157
+ }
158
+
159
+ /** Turn 'mcp__github__pull_request_read' into 'github/pull_request_read'. */
160
+ function prettyTool(name) {
161
+ const m = /^mcp__(.+?)__(.+)$/.exec(String(name || ''));
162
+ return m ? `${m[1]}/${m[2]}` : String(name || '');
163
+ }
164
+
165
+ /**
166
+ * Read the harness out of a transcript. `clean` is server.js's scrub() — every string that can
167
+ * reach the screen goes through it (D08), because tool names and skill names are free text.
168
+ */
169
+ function readHarness(file, clean = (x) => x) {
170
+ const out = { model: null, effort: null, turns: 0, tokens: null, tools: null, skills: null, missing: {} };
171
+ let raw;
172
+ try {
173
+ const st = fs.statSync(file);
174
+ const fd = fs.openSync(file, 'r');
175
+ try {
176
+ const start = Math.max(0, st.size - MAX_BYTES);
177
+ const len = st.size - start;
178
+ const buf = Buffer.allocUnsafe(len);
179
+ fs.readSync(fd, buf, 0, len, start);
180
+ raw = buf.toString('utf8');
181
+ if (start > 0) raw = raw.slice(raw.indexOf('\n') + 1); // drop a partial first line
182
+ } finally { fs.closeSync(fd); }
183
+ } catch {
184
+ out.missing.all = "can't read the harness — the transcript could not be read";
185
+ return out;
186
+ }
187
+
188
+ const toolCounts = {};
189
+ let toolTotal = 0;
190
+ const tok = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
191
+ let sawUsage = false;
192
+
193
+ for (const line of raw.split('\n')) {
194
+ if (!line) continue;
195
+ let r;
196
+ try { r = JSON.parse(line); } catch { continue; } // a torn line is skipped, never guessed
197
+ if (r.type === 'attachment') {
198
+ const a = r.attachment || {};
199
+ if (a.type === 'skill_listing' && typeof a.skillCount === 'number') {
200
+ out.skills = {
201
+ count: a.skillCount,
202
+ names: Array.isArray(a.names) ? a.names.slice(0, 40).map((n) => clean(String(n))) : [],
203
+ };
204
+ }
205
+ continue;
206
+ }
207
+ if (r.type !== 'assistant') continue;
208
+ const m = r.message || {};
209
+ if (m.model && m.model !== SYNTHETIC) {
210
+ out.model = clean(m.model);
211
+ out.turns++; // only real model turns count
212
+ if (r.effort) out.effort = clean(String(r.effort));
213
+ }
214
+ const u = m.usage || {};
215
+ if (typeof u.input_tokens === 'number' || typeof u.output_tokens === 'number') {
216
+ sawUsage = true;
217
+ tok.input += u.input_tokens || 0;
218
+ tok.output += u.output_tokens || 0;
219
+ tok.cacheRead += u.cache_read_input_tokens || 0;
220
+ tok.cacheCreate += u.cache_creation_input_tokens || 0;
221
+ }
222
+ for (const c of (m.content || [])) {
223
+ if (c && c.type === 'tool_use' && c.name) {
224
+ const name = clean(prettyTool(c.name));
225
+ toolCounts[name] = (toolCounts[name] || 0) + 1;
226
+ toolTotal++;
227
+ }
228
+ }
229
+ }
230
+
231
+ if (!out.model) out.missing.model = 'the transcript names no model yet';
232
+ if (!out.effort) out.missing.effort = 'the transcript records no effort level';
233
+ if (toolTotal) out.tools = { counts: toolCounts, total: toolTotal };
234
+ else out.missing.tools = 'no tool calls in the transcript yet';
235
+ // Tokens are reported as ABSOLUTE counts and never as a percentage: the context window size
236
+ // is not in the record, and autocompaction makes the running total move backward. A percentage
237
+ // of an unknown denominator is not a quantity — that refusal predates this module and stands.
238
+ if (sawUsage) out.tokens = tok;
239
+ else out.missing.tokens = 'the transcript records no token usage yet';
240
+ if (!out.skills) out.missing.skills = 'the CLI listed no skills for this session';
241
+ if (!out.turns) out.missing.turns = 'no model turns in the transcript yet';
242
+
243
+ return out;
244
+ }
245
+
246
+ module.exports = { findTranscript, readHarness, prettyTool, slugFor, resolvePid, descendants,
247
+ MAX_BYTES, MAX_WALK_DEPTH };