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.
- package/README.md +114 -0
- package/bin/create-pmos.js +93 -7
- package/package.json +7 -3
- package/payload/AGENTS-HUMAN.md +6 -0
- package/payload/AGENTS.md +35 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/README.md +6 -0
- package/payload/SECURITY.md +6 -0
- package/payload/okf/core/concepts/frontmatter-format.md +75 -0
- package/payload/okf/core/concepts/index.md +3 -0
- package/payload/okf/core/concepts/log.md +13 -0
- package/payload/okf/core/templates/eval-rubric-template.md +12 -10
- package/payload/okf/core/templates/prd-template.md +6 -0
- package/payload/okf/core/templates/prototype-brief-template.md +45 -21
- package/payload/planning/evals/agent-run-eval-rubric.md +10 -7
- package/payload/scripts/metrics.py +142 -121
- package/payload/skills/SKILL-FORMAT.md +6 -0
- package/payload/skills/discovery.skill +4 -0
- package/payload/skills/eval-rubric.skill +1 -1
- package/payload/skills/explain-surface.skill +156 -0
- package/payload/skills/pmos-router.skill +25 -3
- package/payload/skills/prd.skill +8 -0
- package/payload/skills/prototype.skill +106 -41
- package/payload/skills-native/discovery/SKILL.md +4 -0
- package/payload/skills-native/eval-rubric/SKILL.md +1 -1
- package/payload/skills-native/explain-surface/SKILL.md +158 -0
- package/payload/skills-native/pmos-router/SKILL.md +25 -3
- package/payload/skills-native/prd/SKILL.md +8 -0
- package/payload/skills-native/prototype/SKILL.md +106 -41
- package/payload/templates/agent-run-eval-rubric.md +10 -7
- package/payload/templates/ticket-template.md +2 -0
- package/workspace/README.md +112 -0
- package/workspace/REMOTE.md +131 -0
- package/workspace/bin/pmos.js +206 -0
- package/workspace/doctor.js +99 -0
- package/workspace/lib/console-state.js +117 -0
- package/workspace/lib/file-mode-parse.js +58 -0
- package/workspace/lib/harness-parse.js +222 -0
- package/workspace/lib/preview-kind.js +98 -0
- package/workspace/lib/rate-limit.js +26 -0
- package/workspace/lib/remote-access.js +235 -0
- package/workspace/lib/review-gate.js +39 -0
- package/workspace/lib/schedule-logic.js +132 -0
- package/workspace/lib/skill-store.js +143 -0
- package/workspace/lib/surface-contract.js +79 -0
- package/workspace/lib/transcript-read.js +247 -0
- package/workspace/package-lock.json +94 -0
- package/workspace/package.json +23 -0
- package/workspace/public/app.js +1710 -0
- package/workspace/public/index.html +175 -0
- package/workspace/public/styles.css +766 -0
- package/workspace/server.js +1753 -0
- package/workspace/skills/backlog-triage.skill +13 -0
- package/workspace/skills/weekly-report.skill +19 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* pmos — one-word launcher for the PMOS Terminal Workspace (Herdr-style).
|
|
3
|
+
*
|
|
4
|
+
* pmos open THIS directory's repo as a workspace (starting the server if needed)
|
|
5
|
+
* pmos <path> open another directory as a workspace
|
|
6
|
+
* pmos status is the server running? which workspaces, which sessions?
|
|
7
|
+
* pmos ls list the registered workspaces
|
|
8
|
+
* pmos stop stop the server (running sessions end — it says so first)
|
|
9
|
+
* pmos doctor diagnose the environment (node-pty arch, PTY spawn, …)
|
|
10
|
+
*
|
|
11
|
+
* The directory you type `pmos` in IS the workspace: it is resolved to its git root and
|
|
12
|
+
* registered with the server, and the browser opens focused on it. One server hosts many
|
|
13
|
+
* repos — opening a second repo never disturbs the first one's running sessions.
|
|
14
|
+
*
|
|
15
|
+
* The server is started DETACHED: closing this terminal does not kill it — sessions
|
|
16
|
+
* keep running until `pmos stop` (the persistent-server model, R7). Dependency-free.
|
|
17
|
+
*/
|
|
18
|
+
'use strict';
|
|
19
|
+
const http = require('http');
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const { spawn, spawnSync } = require('child_process');
|
|
23
|
+
|
|
24
|
+
const WS = path.resolve(__dirname, '..'); // workspace/
|
|
25
|
+
const STATE = path.join(WS, 'state');
|
|
26
|
+
const PORT = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : 7433;
|
|
27
|
+
// pid/log are PER-PORT: a second workspace (or a test server) on another port must never clobber
|
|
28
|
+
// this one's pid file — that is exactly how `pmos stop` was left unable to find the real server.
|
|
29
|
+
const PIDFILE = path.join(STATE, `server-${PORT}.pid`);
|
|
30
|
+
const LOGFILE = path.join(STATE, `server-${PORT}.log`);
|
|
31
|
+
const URL_ = `http://127.0.0.1:${PORT}`;
|
|
32
|
+
const NO_OPEN = process.env.PMOS_NO_OPEN === '1'; // tests set this
|
|
33
|
+
|
|
34
|
+
const say = (s) => console.log(s);
|
|
35
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
36
|
+
|
|
37
|
+
function probe() {
|
|
38
|
+
return new Promise((res) => {
|
|
39
|
+
const req = http.get(`${URL_}/api/sessions`, { timeout: 900 }, (r) => {
|
|
40
|
+
let b = ''; r.on('data', (c) => (b += c));
|
|
41
|
+
r.on('end', () => { try { res(JSON.parse(b)); } catch { res(null); } });
|
|
42
|
+
});
|
|
43
|
+
req.on('error', () => res(null));
|
|
44
|
+
req.on('timeout', () => { req.destroy(); res(null); });
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
function api(method, pathname, body) {
|
|
48
|
+
return new Promise((res) => {
|
|
49
|
+
const data = body ? JSON.stringify(body) : null;
|
|
50
|
+
const req = http.request(`${URL_}${pathname}`, {
|
|
51
|
+
method, timeout: 3000,
|
|
52
|
+
headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {},
|
|
53
|
+
}, (r) => { let b = ''; r.on('data', (c) => (b += c)); r.on('end', () => { try { res(JSON.parse(b)); } catch { res(null); } }); });
|
|
54
|
+
req.on('error', () => res(null));
|
|
55
|
+
req.on('timeout', () => { req.destroy(); res(null); });
|
|
56
|
+
if (data) req.write(data);
|
|
57
|
+
req.end();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The workspace is the REPO you are in, not the folder you happen to stand in — so `pmos`
|
|
62
|
+
// from a subdirectory opens the whole repo, the same way every git tool behaves.
|
|
63
|
+
function repoRootOf(dir) {
|
|
64
|
+
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: dir, encoding: 'utf8' });
|
|
65
|
+
const top = r.status === 0 ? String(r.stdout).trim() : '';
|
|
66
|
+
return top && fs.existsSync(top) ? top : path.resolve(dir);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function openBrowser(url) {
|
|
70
|
+
if (NO_OPEN) return;
|
|
71
|
+
const cmd = process.platform === 'darwin' ? ['open', [url]]
|
|
72
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
|
|
73
|
+
: ['xdg-open', [url]];
|
|
74
|
+
try { spawn(cmd[0], cmd[1], { detached: true, stdio: 'ignore' }).unref(); } catch {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ensureInstalled() {
|
|
78
|
+
if (fs.existsSync(path.join(WS, 'node_modules', 'node-pty'))) return true;
|
|
79
|
+
say('First run — installing the workspace (one-time, ~20s)…');
|
|
80
|
+
const r = spawnSync(process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
|
81
|
+
['install', '--no-audit', '--no-fund'], { cwd: WS, stdio: 'inherit' });
|
|
82
|
+
if (r.status !== 0) {
|
|
83
|
+
say('✗ install failed. Run `npm run doctor` inside the workspace folder for the exact fix.');
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Register the target directory and open the browser ON IT. The `focus` broadcast matters:
|
|
90
|
+
// the OS may refocus an already-open tab instead of opening our URL, and without it the PM
|
|
91
|
+
// would be looking at the previously-opened repo — the defect this replaces.
|
|
92
|
+
async function openWorkspace(dir) {
|
|
93
|
+
const root = repoRootOf(dir);
|
|
94
|
+
const reg = await api('POST', '/api/workspaces', { root, name: path.basename(root) });
|
|
95
|
+
if (!reg || !reg.ok) {
|
|
96
|
+
say(`✗ could not open ${root}${reg && reg.error ? ` — ${reg.error}` : ''}`);
|
|
97
|
+
openBrowser(URL_);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
const w = reg.workspace;
|
|
101
|
+
say(`✓ workspace “${w.name}” — ${URL_}?ws=${w.id}`);
|
|
102
|
+
say(` ${root}`);
|
|
103
|
+
openBrowser(`${URL_}?ws=${w.id}`);
|
|
104
|
+
return 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function up(dir) {
|
|
108
|
+
if (await probe()) return openWorkspace(dir);
|
|
109
|
+
if (!ensureInstalled()) return 1;
|
|
110
|
+
fs.mkdirSync(STATE, { recursive: true });
|
|
111
|
+
const log = fs.openSync(LOGFILE, 'a');
|
|
112
|
+
const child = spawn(process.execPath, [path.join(WS, 'server.js')], {
|
|
113
|
+
cwd: WS, detached: true, stdio: ['ignore', log, log], env: process.env,
|
|
114
|
+
});
|
|
115
|
+
child.unref();
|
|
116
|
+
try { fs.writeFileSync(PIDFILE, String(child.pid)); } catch {}
|
|
117
|
+
process.stdout.write('starting the workspace ');
|
|
118
|
+
for (let i = 0; i < 40; i++) { // up to ~10s
|
|
119
|
+
if (await probe()) {
|
|
120
|
+
say('');
|
|
121
|
+
say(' sessions keep running after you close this terminal · `pmos stop` shuts it down');
|
|
122
|
+
return openWorkspace(dir);
|
|
123
|
+
}
|
|
124
|
+
process.stdout.write('.');
|
|
125
|
+
await sleep(250);
|
|
126
|
+
}
|
|
127
|
+
say(`\n✗ the server did not come up. Its log says:`);
|
|
128
|
+
try { say(' ' + fs.readFileSync(LOGFILE, 'utf8').trim().split('\n').slice(-6).join('\n ')); } catch {}
|
|
129
|
+
say(' Run `pmos doctor` for a diagnosis.');
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function status() {
|
|
134
|
+
const s = await probe();
|
|
135
|
+
if (!s) { say(`○ not running (start it with: pmos)`); return 1; }
|
|
136
|
+
const wl = (await api('GET', '/api/workspaces')) || { workspaces: [] };
|
|
137
|
+
const live = s.sessions.filter((x) => x.state !== 'exited').length;
|
|
138
|
+
say(`✓ running — ${URL_}`);
|
|
139
|
+
say(` ${wl.workspaces.length} workspace(s): ${wl.workspaces.map((w) => w.name).join(' · ') || 'none'}`);
|
|
140
|
+
say(` ${s.sessions.length} session(s), ${live} live: ${s.sessions.map((x) => `${x.name}[${x.state}]`).join(' · ') || 'none'}`);
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function ls() {
|
|
145
|
+
if (!(await probe())) { say('○ not running (start it with: pmos)'); return 1; }
|
|
146
|
+
const wl = (await api('GET', '/api/workspaces')) || { workspaces: [] };
|
|
147
|
+
if (!wl.workspaces.length) { say('no workspaces registered.'); return 0; }
|
|
148
|
+
for (const w of wl.workspaces) {
|
|
149
|
+
say(`${w.home ? '★' : '·'} ${w.name} ${w.sessions} session(s) [${w.shape}]`);
|
|
150
|
+
say(` ${w.root}`);
|
|
151
|
+
}
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The pid of whatever is LISTENING on our port — the fallback when the pid file is missing
|
|
156
|
+
// (a crash, or a stale run that removed it). posix only; Windows keeps the manual message.
|
|
157
|
+
function listenerPid() {
|
|
158
|
+
if (process.platform === 'win32') return null;
|
|
159
|
+
try {
|
|
160
|
+
const r = spawnSync('lsof', ['-ti', `tcp:${PORT}`, '-sTCP:LISTEN'], { encoding: 'utf8' });
|
|
161
|
+
const pid = Number(String(r.stdout || '').split('\n')[0].trim());
|
|
162
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
163
|
+
} catch { return null; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function stop() {
|
|
167
|
+
if (!(await probe())) { say('○ not running.'); try { fs.unlinkSync(PIDFILE); } catch {} return 0; }
|
|
168
|
+
let pid = null;
|
|
169
|
+
try { pid = Number(fs.readFileSync(PIDFILE, 'utf8').trim()); } catch {}
|
|
170
|
+
if (!pid) pid = listenerPid(); // pid file lost — find the server by the port it owns
|
|
171
|
+
if (!pid) { say(`✗ could not locate the server process (no pid file, and nothing I can see owns port ${PORT}). Stop it manually.`); return 1; }
|
|
172
|
+
// sessions are tmux-backed: stopping the server leaves them running; the next `pmos` re-attaches.
|
|
173
|
+
say('stopping the workspace — sessions keep running in tmux and re-attach on the next launch…');
|
|
174
|
+
try { process.kill(pid); } catch (e) { say(`✗ could not signal pid ${pid}: ${e.message}`); return 1; }
|
|
175
|
+
for (let i = 0; i < 20; i++) { if (!(await probe())) { say('✓ stopped.'); try { fs.unlinkSync(PIDFILE); } catch {} return 0; } await sleep(250); }
|
|
176
|
+
say('✗ still responding after 5s — kill it manually.'); return 1;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function doctor() {
|
|
180
|
+
const r = spawnSync(process.execPath, [path.join(WS, 'doctor.js')], { stdio: 'inherit' });
|
|
181
|
+
return r.status || 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
(async () => {
|
|
185
|
+
const arg = process.argv[2];
|
|
186
|
+
const cmd = (arg || 'up').toLowerCase();
|
|
187
|
+
let code = 0;
|
|
188
|
+
if (cmd === 'status') code = await status();
|
|
189
|
+
else if (cmd === 'ls' || cmd === 'list') code = await ls();
|
|
190
|
+
else if (cmd === 'stop') code = await stop();
|
|
191
|
+
else if (cmd === 'doctor') code = doctor();
|
|
192
|
+
else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
193
|
+
say('usage: pmos [<path>|status|ls|stop|doctor]');
|
|
194
|
+
say(' pmos open the current repo as a workspace (starts the server if needed)');
|
|
195
|
+
say(' pmos <path> open another repo as a workspace');
|
|
196
|
+
code = 0;
|
|
197
|
+
} else if (cmd === 'up' || cmd === 'start' || cmd === 'open') {
|
|
198
|
+
// `pmos open ../other-repo` — the path is the argument AFTER the verb
|
|
199
|
+
code = await up(process.argv[3] ? path.resolve(process.argv[3]) : process.cwd());
|
|
200
|
+
} else if (arg && !arg.startsWith('-')) {
|
|
201
|
+
code = await up(path.resolve(arg)); // `pmos ../other-repo`
|
|
202
|
+
} else {
|
|
203
|
+
code = await up(process.cwd());
|
|
204
|
+
}
|
|
205
|
+
process.exit(code);
|
|
206
|
+
})();
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* pmos workspace doctor — diagnoses the environment the server needs.
|
|
3
|
+
* Checks, in order: Node version → node-pty loads → native binary arch matches the Node
|
|
4
|
+
* process → a real PTY actually spawns and echoes. Prints the exact fix for each failure.
|
|
5
|
+
* Exit 0 = healthy; exit 1 = something needs fixing (the output says what). */
|
|
6
|
+
'use strict';
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { spawnSync } = require('child_process');
|
|
10
|
+
|
|
11
|
+
const checks = [];
|
|
12
|
+
const ok = (name, extra) => { checks.push(true); console.log(`✓ ${name}${extra ? ' — ' + extra : ''}`); };
|
|
13
|
+
const bad = (name, fix) => { checks.push(false); console.log(`✗ ${name}`); if (fix) console.log(` fix: ${fix}`); };
|
|
14
|
+
|
|
15
|
+
// Read the CPU arch a compiled binary was built for, from its magic bytes (no external tools).
|
|
16
|
+
function binaryArch(file) {
|
|
17
|
+
try {
|
|
18
|
+
const b = Buffer.alloc(8);
|
|
19
|
+
const fd = fs.openSync(file, 'r'); fs.readSync(fd, b, 0, 8, 0); fs.closeSync(fd);
|
|
20
|
+
if (b[0] === 0x7f && b[1] === 0x45) return { x: 'ELF', arch: b[4] === 2 ? (b.readUInt16LE ? 'elf64' : 'elf') : 'elf32' };
|
|
21
|
+
const m = b.readUInt32LE(0);
|
|
22
|
+
if (m === 0xfeedfacf) { const cpu = b.readUInt32LE(4); return { x: 'Mach-O', arch: cpu === 0x0100000c ? 'arm64' : cpu === 0x01000007 ? 'x86_64' : 'cpu:' + cpu.toString(16) }; }
|
|
23
|
+
if (m === 0xbebafeca || m === 0xcafebabe) return { x: 'Mach-O universal', arch: 'universal' };
|
|
24
|
+
if (b[0] === 0x4d && b[1] === 0x5a) return { x: 'PE', arch: 'windows' };
|
|
25
|
+
} catch {}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
(async () => {
|
|
30
|
+
console.log(`pmos workspace doctor — node ${process.version} (${process.platform}/${process.arch})\n`);
|
|
31
|
+
|
|
32
|
+
// 1. Node version
|
|
33
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
34
|
+
if (major >= 18) ok(`Node ${process.version} (>= 18)`);
|
|
35
|
+
else bad(`Node ${process.version} is too old`, 'install Node 18+ (arm64-native on Apple Silicon)');
|
|
36
|
+
|
|
37
|
+
// 2. node-pty loads
|
|
38
|
+
let pty = null;
|
|
39
|
+
try { pty = require('node-pty'); ok('node-pty native module loads'); }
|
|
40
|
+
catch (e) {
|
|
41
|
+
bad(`node-pty failed to load: ${e.message.split('\n')[0]}`,
|
|
42
|
+
'npm rebuild node-pty --build-from-source (needs: macOS `xcode-select --install`; Linux `apt install build-essential python3`)');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 3. native binary arch matches the running Node (the posix_spawnp culprit)
|
|
46
|
+
const helper = path.join(__dirname, 'node_modules', 'node-pty', 'build', 'Release',
|
|
47
|
+
process.platform === 'win32' ? 'pty.node' : 'spawn-helper');
|
|
48
|
+
const alt = path.join(__dirname, 'node_modules', 'node-pty', 'build', 'Release', 'pty.node');
|
|
49
|
+
const bin = fs.existsSync(helper) ? helper : (fs.existsSync(alt) ? alt : null);
|
|
50
|
+
if (!bin) bad('node-pty native binary not found (build incomplete)', 'rm -rf node_modules && npm install');
|
|
51
|
+
else {
|
|
52
|
+
const info = binaryArch(bin);
|
|
53
|
+
const want = process.arch; // arm64 | x64 | …
|
|
54
|
+
const got = info && info.arch;
|
|
55
|
+
const match = !info || info.arch === 'universal' || info.x === 'ELF' || info.x === 'PE' ||
|
|
56
|
+
(want === 'arm64' && got === 'arm64') || (want === 'x64' && got === 'x86_64');
|
|
57
|
+
if (match) ok(`native binary present${got && got !== 'universal' && info.x === 'Mach-O' ? ` (${got})` : ''}`);
|
|
58
|
+
else bad(`ARCH MISMATCH: Node is ${want} but node-pty's binary is ${got} — this causes 'posix_spawnp failed' on every session`,
|
|
59
|
+
`install a ${want}-native Node (e.g. \`brew install node\` on an arm64 shell), then: rm -rf node_modules && npm install`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 4. a real PTY spawns and echoes
|
|
63
|
+
if (pty) {
|
|
64
|
+
const sh = process.platform === 'win32' ? (process.env.COMSPEC || 'powershell.exe') : '/bin/sh';
|
|
65
|
+
try {
|
|
66
|
+
const out = await new Promise((resolve, reject) => {
|
|
67
|
+
let acc = '';
|
|
68
|
+
const p = pty.spawn(sh, [], { name: 'xterm-256color', cols: 80, rows: 24, cwd: __dirname, env: process.env });
|
|
69
|
+
const t = setTimeout(() => { try { p.kill(); } catch {}; reject(new Error('timeout — shell spawned but never echoed')); }, 4000);
|
|
70
|
+
p.onData((d) => { acc += d; if (acc.includes('DOCTOR_OK')) { clearTimeout(t); try { p.kill(); } catch {}; resolve(acc); } });
|
|
71
|
+
p.onExit(() => { if (!acc.includes('DOCTOR_OK')) { clearTimeout(t); reject(new Error(`shell exited before echoing (got ${acc.length} bytes)`)); } });
|
|
72
|
+
p.write('echo DOCTOR_OK\r');
|
|
73
|
+
});
|
|
74
|
+
ok(`real PTY spawns + echoes (${sh})`);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
bad(`PTY spawn failed: ${e.message}`,
|
|
77
|
+
/posix_spawnp/.test(e.message)
|
|
78
|
+
? 'this is the broken-native-build signature — npm rebuild node-pty --build-from-source (and see the arch check above)'
|
|
79
|
+
: process.platform === 'win32' ? 'native Windows needs WSL for a real PTY — run the workspace inside WSL'
|
|
80
|
+
: 'check that /bin/sh exists and is executable');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 5. tmux — the session-persistence backing. Not fatal: absent, the server falls back to
|
|
85
|
+
// direct PTYs (sessions still work, but a `pmos stop`/restart ends them). Present, a session
|
|
86
|
+
// OUTLIVES a server restart and is re-attached live. This check explains which mode you're in.
|
|
87
|
+
const tmuxV = spawnSync('tmux', ['-V'], { encoding: 'utf8' });
|
|
88
|
+
if (tmuxV.status === 0) {
|
|
89
|
+
ok(`tmux present (${String(tmuxV.stdout).trim()}) — sessions PERSIST across \`pmos stop\`/restart`);
|
|
90
|
+
} else {
|
|
91
|
+
// a soft note, not a ✗: everything still works, just without restart-proof sessions
|
|
92
|
+
console.log('• tmux not found — sessions run on direct PTYs and END on `pmos stop`/restart (no live persistence).');
|
|
93
|
+
console.log(' optional fix: install tmux (macOS `brew install tmux`; Linux `apt install tmux`) for restart-proof sessions.');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const fails = checks.filter((c) => !c).length;
|
|
97
|
+
console.log(`\n${checks.length - fails}/${checks.length} checks passed${fails ? ' — fix the ✗ items above, then re-run npm run doctor' : ' — environment healthy, npm start away'}`);
|
|
98
|
+
process.exit(fails ? 1 : 0);
|
|
99
|
+
})();
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Read-only console state readers (D64 / pivot-workspace-first, Track B).
|
|
4
|
+
*
|
|
5
|
+
* The workspace console surfaces — the "needs you" queue, a radar-style health summary, and a
|
|
6
|
+
* metrics view — derive ONLY from local file-mode state: the kit's `pmos/state/*.jsonl` and, as a
|
|
7
|
+
* seed/demo source when no live state exists yet, the committed hosted snapshot under
|
|
8
|
+
* `planning/data-export/<date>-hosted-snapshot/*.json`. Live file-mode state is ALWAYS preferred.
|
|
9
|
+
*
|
|
10
|
+
* HONESTY BY CONSTRUCTION (the anti-watermelon rule): every reader returns a discriminated result —
|
|
11
|
+
* { ok:true, empty:false, source, data } data present
|
|
12
|
+
* { ok:true, empty:true, source } genuinely nothing (calm empty state on the client)
|
|
13
|
+
* { ok:false, error } a missing source / parse failure — the client MUST
|
|
14
|
+
* render this RED, never a green "all clear".
|
|
15
|
+
* A missing DATA source (no live file AND no snapshot) is an ERROR, not an empty — a health surface
|
|
16
|
+
* that cannot read its inputs is broken, and a broken honesty surface must read red.
|
|
17
|
+
*/
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const reviewGate = require('./review-gate');
|
|
21
|
+
|
|
22
|
+
function loadJson(file) {
|
|
23
|
+
let text;
|
|
24
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return { missing: true }; }
|
|
25
|
+
try { return { ok: true, data: JSON.parse(text) }; }
|
|
26
|
+
catch { return { ok: false, error: `${path.basename(file)} is not valid JSON — the file-mode source is corrupt, not empty` }; }
|
|
27
|
+
}
|
|
28
|
+
function loadJsonl(file) {
|
|
29
|
+
let text;
|
|
30
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return { missing: true }; }
|
|
31
|
+
const rows = []; let bad = 0;
|
|
32
|
+
for (const line of text.split('\n')) {
|
|
33
|
+
const t = line.trim(); if (!t) continue;
|
|
34
|
+
try { rows.push(JSON.parse(t)); } catch { bad++; }
|
|
35
|
+
}
|
|
36
|
+
// A file that is entirely unparseable is corrupt state — surface it red rather than call it empty.
|
|
37
|
+
if (bad > 0 && rows.length === 0) return { ok: false, error: `every line in ${path.basename(file)} is malformed — corrupt file-mode state` };
|
|
38
|
+
return { ok: true, data: rows, bad };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function computeMetricsFromRuns(runs) {
|
|
42
|
+
const corr = runs.map((r) => Number(r.human_corrections) || 0);
|
|
43
|
+
const total = corr.reduce((a, b) => a + b, 0);
|
|
44
|
+
const needing = corr.filter((c) => c > 0).length;
|
|
45
|
+
const n = runs.length;
|
|
46
|
+
return {
|
|
47
|
+
total_runs: n,
|
|
48
|
+
avg_corrections_per_run: n ? +(total / n).toFixed(3) : 0,
|
|
49
|
+
runs_needing_correction: needing,
|
|
50
|
+
share_runs_corrected: n ? +(needing / n).toFixed(3) : 0,
|
|
51
|
+
per_initiative: [],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── metrics: prefer live pmos/state/runs.jsonl; fall back to the snapshot's metrics.json ──
|
|
56
|
+
function metrics({ stateDir, snapshotDir }) {
|
|
57
|
+
if (stateDir) {
|
|
58
|
+
const live = loadJsonl(path.join(stateDir, 'runs.jsonl'));
|
|
59
|
+
if (live.ok) {
|
|
60
|
+
if (!live.data.length) return { ok: true, empty: true, source: 'file-mode' };
|
|
61
|
+
return { ok: true, empty: false, source: 'file-mode', data: computeMetricsFromRuns(live.data) };
|
|
62
|
+
}
|
|
63
|
+
if (live.ok === false) return { ok: false, error: live.error };
|
|
64
|
+
// live.missing → fall through to the snapshot
|
|
65
|
+
}
|
|
66
|
+
if (!snapshotDir) return { ok: false, error: 'no metrics source — no live runs.jsonl and no hosted snapshot for this workspace' };
|
|
67
|
+
const snap = loadJson(path.join(snapshotDir, 'metrics.json'));
|
|
68
|
+
if (snap.missing) return { ok: false, error: 'metrics source missing — neither live runs.jsonl nor the hosted snapshot is present' };
|
|
69
|
+
if (!snap.ok) return { ok: false, error: snap.error };
|
|
70
|
+
const m = snap.data || {};
|
|
71
|
+
return { ok: true, empty: false, source: 'snapshot', data: {
|
|
72
|
+
total_runs: m.total_runs, avg_corrections_per_run: m.avg_corrections_per_run,
|
|
73
|
+
runs_needing_correction: m.runs_needing_correction, share_runs_corrected: m.share_runs_corrected,
|
|
74
|
+
per_initiative: Array.isArray(m.per_initiative) ? m.per_initiative.slice(0, 12) : [],
|
|
75
|
+
} };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── radar: prefer a live pmos/state/radar.json; fall back to the snapshot's radar_all_sections.json ──
|
|
79
|
+
function radar({ stateDir, snapshotDir }) {
|
|
80
|
+
let src = null, source = null;
|
|
81
|
+
if (stateDir) {
|
|
82
|
+
const live = loadJson(path.join(stateDir, 'radar.json'));
|
|
83
|
+
if (live.ok) { src = live.data; source = 'file-mode'; }
|
|
84
|
+
else if (live.ok === false) return { ok: false, error: live.error };
|
|
85
|
+
}
|
|
86
|
+
if (!src) {
|
|
87
|
+
if (!snapshotDir) return { ok: false, error: 'no radar source — no live radar.json and no hosted snapshot for this workspace' };
|
|
88
|
+
const snap = loadJson(path.join(snapshotDir, 'radar_all_sections.json'));
|
|
89
|
+
if (snap.missing) return { ok: false, error: 'radar source missing — neither live radar.json nor the hosted snapshot is present' };
|
|
90
|
+
if (!snap.ok) return { ok: false, error: snap.error };
|
|
91
|
+
src = snap.data; source = 'snapshot';
|
|
92
|
+
}
|
|
93
|
+
const d = src || {};
|
|
94
|
+
const gates = Array.isArray(d.eval_gates) ? d.eval_gates : [];
|
|
95
|
+
const passing = gates.filter((g) => reviewGate.isReviewPass(g.verdict, g.score)).length;
|
|
96
|
+
const wms = (Array.isArray(d.watermelons) ? d.watermelons : []).map((w) => ({
|
|
97
|
+
id: w.id, title: w.title, rag: w.rag,
|
|
98
|
+
verdict: w.latest_eval_verdict, score: w.latest_eval_score, corrections: w.avg_human_corrections,
|
|
99
|
+
}));
|
|
100
|
+
const calRaw = Array.isArray(d.calibration) ? d.calibration[0] : d.calibration;
|
|
101
|
+
const calibration = calRaw ? {
|
|
102
|
+
n: calRaw.n, kappa: calRaw.kappa, tnr: calRaw.tnr, status: calRaw.status,
|
|
103
|
+
meets_d40: !!calRaw.meets_d40_kappa, caveat: calRaw.leakage_caveat || null,
|
|
104
|
+
} : null;
|
|
105
|
+
const okr = (Array.isArray(d.okr_pulse) ? d.okr_pulse : []).slice(0, 8).map((o) => ({
|
|
106
|
+
objective: o.objective, kr: o.kr, progress: o.progress_pct, pulse: o.pulse,
|
|
107
|
+
}));
|
|
108
|
+
return { ok: true, empty: false, source, data: {
|
|
109
|
+
// watermelon flags = anything not green (amber/red) leads; the whole point is to surface drift
|
|
110
|
+
watermelons: wms,
|
|
111
|
+
flags: wms.filter((w) => w.rag && String(w.rag).toLowerCase() !== 'green').length,
|
|
112
|
+
eval_gate: { total: gates.length, passing, pass_rate: gates.length ? +(passing / gates.length).toFixed(3) : null },
|
|
113
|
+
calibration, okr_pulse: okr,
|
|
114
|
+
} };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { metrics, radar, computeMetricsFromRuns, loadJson, loadJsonl };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Canonical file-mode state parsers (D64 / pivot-workspace-first, Track D1).
|
|
4
|
+
*
|
|
5
|
+
* The workspace server (server.js) and the kit's metrics.py both read the SAME file-mode records —
|
|
6
|
+
* acceptances.jsonl, evals.jsonl, runs.jsonl, and the leading frontmatter of initiative markdown —
|
|
7
|
+
* and MUST agree on what a repo declared. Before D64 each had its own inline copy of these parsers,
|
|
8
|
+
* so they were free to drift. server.js now delegates to this module; metrics.py mirrors it in
|
|
9
|
+
* Python; kit/fixtures/test_parsers.py runs both against kit/fixtures/** and fails CI on disagreement.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const reviewGate = require('./review-gate');
|
|
13
|
+
|
|
14
|
+
// The LEADING YAML-ish frontmatter block ONLY — body text must never be able to fake a declaration.
|
|
15
|
+
// Mirrors metrics.py exactly: `key: value` lines (first value wins), `---` fences and blank lines are
|
|
16
|
+
// skipped, and the FIRST non-frontmatter, non-fence, non-blank line ends the block.
|
|
17
|
+
function parseLeadingFrontmatter(text) {
|
|
18
|
+
const fm = {};
|
|
19
|
+
for (const line of String(text == null ? '' : text).split('\n')) {
|
|
20
|
+
const m = line.match(/^([a-z_]+):\s*(.+?)\s*$/);
|
|
21
|
+
if (m) { if (!(m[1] in fm)) fm[m[1]] = m[2]; continue; }
|
|
22
|
+
if (line.trim() && !line.startsWith('---')) break;
|
|
23
|
+
}
|
|
24
|
+
return fm;
|
|
25
|
+
}
|
|
26
|
+
function leadingFrontmatter(file) {
|
|
27
|
+
let text;
|
|
28
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return {}; }
|
|
29
|
+
return parseLeadingFrontmatter(text);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// One JSON object per line; blank lines skipped, torn lines tolerated (dropped), never guessed.
|
|
33
|
+
function readJsonlRows(file) {
|
|
34
|
+
let text;
|
|
35
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const line of text.split('\n')) {
|
|
38
|
+
const t = line.trim();
|
|
39
|
+
if (!t) continue;
|
|
40
|
+
try { out.push(JSON.parse(t)); } catch { /* torn line — skipped, mirrors metrics.py */ }
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The kit's north-star / throughput field: an acceptance counts only when accepted === true.
|
|
46
|
+
function isAccepted(row) { return !!row && row.accepted === true; }
|
|
47
|
+
|
|
48
|
+
// A declared outcome is DUE when it has an id + outcome_metric and its horizon is past today. A
|
|
49
|
+
// MISSING horizon counts as due (horizon-gaming-by-omission) — identical to metrics.py.
|
|
50
|
+
function outcomeDue(fm, today) {
|
|
51
|
+
if (!fm || !fm.id || !fm.outcome_metric) return false;
|
|
52
|
+
return String(fm.outcome_horizon || '0000-00-00') < String(today);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
parseLeadingFrontmatter, leadingFrontmatter, readJsonlRows, isAccepted, outcomeDue,
|
|
57
|
+
isReviewPass: reviewGate.isReviewPass,
|
|
58
|
+
};
|