create-pmos 0.250.0 → 0.252.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/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/workflow-state.js +3 -0
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/lib/engine/signals/facts.js +1 -1
- package/workspace/lib/engine/workflow-state.js +3 -0
- package/workspace/lib/vendor/scripts/workflow-state.js +3 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/server.js +66 -11
- package/workspace/test-lists/workspace-session-restore.json +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.252.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. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-10-
|
|
1
|
+
kernel-source: pmos@c1539b2
|
|
2
|
+
assembled: 2026-10-16
|
|
3
3
|
mode: vendor
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-16T04:16:43Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-16T04:16:43Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -716,6 +716,9 @@ function computeWorkflowDerived(id, opts) {
|
|
|
716
716
|
}
|
|
717
717
|
return best;
|
|
718
718
|
}
|
|
719
|
+
// A caller that never reads `last_activity` (the facts reader) says so, and the subprocess is not started: one per
|
|
720
|
+
// initiative was 9.5 of the 12 seconds a cold facts read took on this repository.
|
|
721
|
+
if (opts && opts.skipLastActivity) return null;
|
|
719
722
|
const out = git(root, ['log', '-1', '--format=%ct', '--', ...paths]);
|
|
720
723
|
const t = out && parseInt(out.trim(), 10);
|
|
721
724
|
return Number.isFinite(t) ? t : null;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-16T04:16:43Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/payload/state/log.md
CHANGED
|
@@ -92,7 +92,7 @@ function readRepoFacts(root, opts) {
|
|
|
92
92
|
const inis = [];
|
|
93
93
|
for (const id of ws.listInitiatives(base)) {
|
|
94
94
|
let w;
|
|
95
|
-
try { w = ws.computeWorkflow(id, range ? { root: base, range } : { root: base }); } catch { continue; }
|
|
95
|
+
try { w = ws.computeWorkflow(id, range ? { root: base, range, skipLastActivity: true } : { root: base, skipLastActivity: true }); } catch { continue; }
|
|
96
96
|
if (!w || !w.ok) continue;
|
|
97
97
|
const fm = readFrontmatter(path.join(base, w.record || `planning/initiatives/${id}.md`));
|
|
98
98
|
inis.push({
|
|
@@ -716,6 +716,9 @@ function computeWorkflowDerived(id, opts) {
|
|
|
716
716
|
}
|
|
717
717
|
return best;
|
|
718
718
|
}
|
|
719
|
+
// A caller that never reads `last_activity` (the facts reader) says so, and the subprocess is not started: one per
|
|
720
|
+
// initiative was 9.5 of the 12 seconds a cold facts read took on this repository.
|
|
721
|
+
if (opts && opts.skipLastActivity) return null;
|
|
719
722
|
const out = git(root, ['log', '-1', '--format=%ct', '--', ...paths]);
|
|
720
723
|
const t = out && parseInt(out.trim(), 10);
|
|
721
724
|
return Number.isFinite(t) ? t : null;
|
|
@@ -716,6 +716,9 @@ function computeWorkflowDerived(id, opts) {
|
|
|
716
716
|
}
|
|
717
717
|
return best;
|
|
718
718
|
}
|
|
719
|
+
// A caller that never reads `last_activity` (the facts reader) says so, and the subprocess is not started: one per
|
|
720
|
+
// initiative was 9.5 of the 12 seconds a cold facts read took on this repository.
|
|
721
|
+
if (opts && opts.skipLastActivity) return null;
|
|
719
722
|
const out = git(root, ['log', '-1', '--format=%ct', '--', ...paths]);
|
|
720
723
|
const t = out && parseInt(out.trim(), 10);
|
|
721
724
|
return Number.isFinite(t) ? t : null;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.252.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.252.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
package/workspace/server.js
CHANGED
|
@@ -2838,7 +2838,7 @@ function stripLegacyClientScreen(buf) {
|
|
|
2838
2838
|
return buf.replace(TMUX_CLIENT_SMCUP, '').replace(TMUX_CLIENT_RMCUP, '');
|
|
2839
2839
|
}
|
|
2840
2840
|
|
|
2841
|
-
function restorePersisted() {
|
|
2841
|
+
function restorePersisted(opts = {}) {
|
|
2842
2842
|
// On server start, load prior sessions' metadata + scrollback. A tmux-backed session whose
|
|
2843
2843
|
// process is STILL ALIVE (it outlived this server via the tmux daemon) is RE-ATTACHED live —
|
|
2844
2844
|
// the PM's `claude` keeps its in-flight state across `pmos stop`/relaunch, no respawn (R7).
|
|
@@ -2853,10 +2853,13 @@ function restorePersisted() {
|
|
|
2853
2853
|
const n = Number(f.replace(/\.json$/, '').split('-')[1]);
|
|
2854
2854
|
if (Number.isFinite(n) && n >= seq) seq = n + 1;
|
|
2855
2855
|
}
|
|
2856
|
-
for (
|
|
2856
|
+
// One lookup for every live pane, not three per session (has-session, pane_id, pane_pid): with many sessions that was seconds
|
|
2857
|
+
// of blocking subprocesses before the server could answer.
|
|
2858
|
+
const live = tmuxLivePanes();
|
|
2859
|
+
const restoreOne = (f) => {
|
|
2857
2860
|
try {
|
|
2858
2861
|
const meta = JSON.parse(fs.readFileSync(path.join(SESS_DIR, f), 'utf8'));
|
|
2859
|
-
if (!meta.id || sessions.has(meta.id))
|
|
2862
|
+
if (!meta.id || sessions.has(meta.id)) return;
|
|
2860
2863
|
let buf = '';
|
|
2861
2864
|
try { buf = stripLegacyClientScreen(fs.readFileSync(path.join(SESS_DIR, meta.id + '.scrollback'), 'utf8')); } catch {}
|
|
2862
2865
|
// re-home the session: its workspace must exist for it to be reachable in the UI
|
|
@@ -2868,8 +2871,10 @@ function restorePersisted() {
|
|
|
2868
2871
|
lastOutputAt: Date.now(), // a restored session's idle clock starts at boot, never from a missing value
|
|
2869
2872
|
};
|
|
2870
2873
|
sessions.set(meta.id, s);
|
|
2871
|
-
if (TMUX && meta.tmux &&
|
|
2874
|
+
if (TMUX && meta.tmux && live.has(meta.tmux)) {
|
|
2872
2875
|
s.state = 'working'; s.note = '';
|
|
2876
|
+
s.paneId = live.get(meta.tmux).paneId; // stable for the life of the tmux session; saves the pane_id lookup on attach
|
|
2877
|
+
if (live.get(meta.tmux).pid > 0) s.agentPid = live.get(meta.tmux).pid;
|
|
2873
2878
|
// seed: this server was DOWN, so output produced in the gap never reached our ring buffer.
|
|
2874
2879
|
// Control mode replays no history, so take tmux's — it is the superset (R7 + scrollback).
|
|
2875
2880
|
attachPty(s, { seed: true }); // the process is alive — reconnect to it, live
|
|
@@ -2889,8 +2894,23 @@ function restorePersisted() {
|
|
|
2889
2894
|
}
|
|
2890
2895
|
const n = Number(String(meta.id).split('-')[1]); if (Number.isFinite(n) && n >= seq) seq = n + 1;
|
|
2891
2896
|
} catch {}
|
|
2897
|
+
};
|
|
2898
|
+
if (!opts.progressive) {
|
|
2899
|
+
for (const f of files) restoreOne(f);
|
|
2900
|
+
adoptOrphanTmuxSessions();
|
|
2901
|
+
return;
|
|
2892
2902
|
}
|
|
2893
|
-
|
|
2903
|
+
// The running server restores in short slices AFTER it is listening, so the page is reachable at once and the sessions
|
|
2904
|
+
// appear as they come back. Every id was floored above, so a session spawned meanwhile cannot take a restored id.
|
|
2905
|
+
const queue = files.slice();
|
|
2906
|
+
const slice = () => {
|
|
2907
|
+
const t0 = Date.now();
|
|
2908
|
+
while (queue.length && Date.now() - t0 < 15) restoreOne(queue.shift());
|
|
2909
|
+
if (queue.length) { setImmediate(slice); return; }
|
|
2910
|
+
try { adoptOrphanTmuxSessions(); } catch {}
|
|
2911
|
+
try { pushSessions(); } catch {}
|
|
2912
|
+
};
|
|
2913
|
+
setImmediate(slice);
|
|
2894
2914
|
}
|
|
2895
2915
|
|
|
2896
2916
|
// The registered workspace a directory belongs to — the LONGEST matching root, so a repo opened
|
|
@@ -2926,7 +2946,7 @@ function adoptOrphanTmuxSessions() {
|
|
|
2926
2946
|
if (!TMUX) return 0;
|
|
2927
2947
|
const claimed = new Set([...sessions.values()].map((s) => s.tmux).filter(Boolean));
|
|
2928
2948
|
let adopted = 0;
|
|
2929
|
-
for (const t of tmuxList().sort((a, b) => a.created - b.created)) {
|
|
2949
|
+
for (const t of tmuxList(claimed).sort((a, b) => a.created - b.created)) {
|
|
2930
2950
|
if (!t.name.startsWith('pmos-') || claimed.has(t.name)) continue;
|
|
2931
2951
|
const dir = t.dir && isDir(t.dir) ? path.resolve(t.dir) : null;
|
|
2932
2952
|
const start = t.start && isDir(t.start) ? path.resolve(t.start) : null;
|
|
@@ -3415,11 +3435,14 @@ function tmuxField(name, fmt) {
|
|
|
3415
3435
|
const r = tmuxCtl(['display-message', '-p', '-t', name, fmt]);
|
|
3416
3436
|
return r && r.status === 0 ? String(r.stdout || '').trim() : '';
|
|
3417
3437
|
}
|
|
3418
|
-
|
|
3438
|
+
// `skip` names sessions the caller already owns: they are not described, so a restart with many live sessions does not
|
|
3439
|
+
// start five blocking tmux calls for each one of them just to throw the answer away.
|
|
3440
|
+
function tmuxList(skip) {
|
|
3419
3441
|
if (!TMUX) return [];
|
|
3420
3442
|
const r = tmuxCtl(['list-sessions', '-F', '#{session_name}']);
|
|
3421
3443
|
if (!r || r.status !== 0) return [];
|
|
3422
3444
|
return String(r.stdout || '').split('\n').map((line) => line.trim()).filter(Boolean)
|
|
3445
|
+
.filter((name) => !(skip && skip.has(name)))
|
|
3423
3446
|
.map((name) => ({
|
|
3424
3447
|
name,
|
|
3425
3448
|
dir: tmuxField(name, '#{pane_current_path}'),
|
|
@@ -3431,6 +3454,20 @@ function tmuxList() {
|
|
|
3431
3454
|
cmd: tmuxField(name, '#{pane_start_command}'),
|
|
3432
3455
|
}));
|
|
3433
3456
|
}
|
|
3457
|
+
// Every live session's active pane in ONE call: name -> { paneId, pid }. The restore asks it instead of running has-session,
|
|
3458
|
+
// pane_id and pane_pid per session. A pipe separates the fields: tmux rewrites a tab inside a format string (see tmuxField).
|
|
3459
|
+
function tmuxLivePanes() {
|
|
3460
|
+
const out = new Map();
|
|
3461
|
+
if (!TMUX) return out;
|
|
3462
|
+
const r = tmuxCtl(['list-panes', '-a', '-F', '#{session_name}|#{window_active}|#{pane_active}|#{pane_id}|#{pane_pid}']);
|
|
3463
|
+
if (!r || r.status !== 0) return out;
|
|
3464
|
+
for (const line of String(r.stdout || '').split('\n')) {
|
|
3465
|
+
const [name, win, pane, paneId, pid] = line.trim().split('|');
|
|
3466
|
+
if (!name || win !== '1' || pane !== '1' || !/^%\d+$/.test(paneId || '')) continue;
|
|
3467
|
+
if (!out.has(name)) out.set(name, { paneId, pid: Number(pid) });
|
|
3468
|
+
}
|
|
3469
|
+
return out;
|
|
3470
|
+
}
|
|
3434
3471
|
// The pane id (%0, %1, …) the control client must address for input. One cheap lookup per attach;
|
|
3435
3472
|
// cached on the session because a pane id is stable for the life of the tmux session.
|
|
3436
3473
|
function tmuxPaneId(s, name) {
|
|
@@ -5873,13 +5910,30 @@ const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; ch
|
|
|
5873
5910
|
const zlib = require('zlib');
|
|
5874
5911
|
const GZIP_MIN_BYTES = 1024;
|
|
5875
5912
|
const GZIP_TYPES = /^(?:text\/|application\/(?:json|javascript|manifest\+json)|image\/svg\+xml)/;
|
|
5876
|
-
|
|
5913
|
+
// Whether this client takes a gzip body: a `gzip` (or `*`) coding in Accept-Encoding whose q-value is not 0. `gzip;q=0` is a
|
|
5914
|
+
// refusal. PMOS_NO_COMPRESS=1 turns compression off, so a proxy or client that mishandles it can be ruled out in one restart.
|
|
5915
|
+
const NO_COMPRESS = process.env.PMOS_NO_COMPRESS === '1';
|
|
5916
|
+
function wantsGzip(res) {
|
|
5917
|
+
if (NO_COMPRESS) return false;
|
|
5918
|
+
const q = res && res.req;
|
|
5919
|
+
const h = q && q.headers && q.headers['accept-encoding'];
|
|
5920
|
+
if (!h) return false;
|
|
5921
|
+
for (const part of String(h).split(',')) {
|
|
5922
|
+
const [coding, ...params] = part.trim().toLowerCase().split(';');
|
|
5923
|
+
if (coding !== 'gzip') continue;
|
|
5924
|
+
const qv = params.map((x) => x.trim()).find((x) => x.startsWith('q='));
|
|
5925
|
+
return !(qv && Number(qv.slice(2)) === 0);
|
|
5926
|
+
}
|
|
5927
|
+
return false;
|
|
5928
|
+
}
|
|
5877
5929
|
const fileMemo = new Map(); // file -> { sig, buf, etag, gz }
|
|
5878
5930
|
const FILE_MEMO_MAX = 400;
|
|
5879
5931
|
function serveFile(res, file, type, noCache) {
|
|
5880
5932
|
fs.stat(file, (serr, st) => {
|
|
5881
5933
|
if (serr || !st.isFile()) { res.writeHead(404); res.end('not found'); return; }
|
|
5882
|
-
|
|
5934
|
+
// size, mtime, change time and inode: a package install stamps every file with one fixed mtime, so size and mtime alone
|
|
5935
|
+
// would keep serving the old bytes after a same-size edit.
|
|
5936
|
+
const sig = `${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}`;
|
|
5883
5937
|
const send = (m) => {
|
|
5884
5938
|
const ctype = type || TYPES[path.extname(file)] || 'application/octet-stream';
|
|
5885
5939
|
const headers = { 'Content-Type': ctype, 'ETag': m.etag };
|
|
@@ -5902,7 +5956,7 @@ function serveFile(res, file, type, noCache) {
|
|
|
5902
5956
|
if (hit && hit.sig === sig) return send(hit);
|
|
5903
5957
|
fs.readFile(file, (err, buf) => {
|
|
5904
5958
|
if (err) { res.writeHead(404); res.end('not found'); return; }
|
|
5905
|
-
const m = { sig, buf, etag: `
|
|
5959
|
+
const m = { sig, buf, etag: `"${require('crypto').createHash('sha1').update(buf).digest('hex').slice(0, 20)}"`, gz: null };
|
|
5906
5960
|
const ctype = type || TYPES[path.extname(file)] || '';
|
|
5907
5961
|
const finish = () => { if (fileMemo.size >= FILE_MEMO_MAX) fileMemo.clear(); fileMemo.set(file, m); send(m); };
|
|
5908
5962
|
if (buf.length >= GZIP_MIN_BYTES && GZIP_TYPES.test(ctype)) zlib.gzip(buf, (e, gz) => { if (!e) m.gz = gz; finish(); });
|
|
@@ -6021,6 +6075,7 @@ function json(res, obj, code = 200) {
|
|
|
6021
6075
|
if (body.length >= GZIP_MIN_BYTES && wantsGzip(res)) {
|
|
6022
6076
|
// Async, so a 1 MB library answer costs the loop nothing beyond the stringify (workspace-event-loop-lag).
|
|
6023
6077
|
return zlib.gzip(body, (e, gz) => {
|
|
6078
|
+
if (res.headersSent || res.writableEnded || res.destroyed) return; // the handler or the client got there first
|
|
6024
6079
|
if (e) { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(body); return; }
|
|
6025
6080
|
res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip', 'Vary': 'Accept-Encoding', 'Content-Length': gz.length });
|
|
6026
6081
|
res.end(gz);
|
|
@@ -9638,7 +9693,7 @@ wss.on('connection', (ws) => {
|
|
|
9638
9693
|
loadWorkspaces();
|
|
9639
9694
|
if (HOME_ENABLED) registerWorkspace(SELF_ROOT); // the home workspace is hosted (PMOS-self, or the repo the launcher named)
|
|
9640
9695
|
writeTmuxConf(); // must exist before restore may reattach or spawn creates
|
|
9641
|
-
restorePersisted();
|
|
9696
|
+
restorePersisted({ progressive: !REQUIRED_AS_LIBRARY });
|
|
9642
9697
|
|
|
9643
9698
|
// A first shell so the workspace is never empty on first run (honest real PTY). Only when there
|
|
9644
9699
|
// is a repo to open it IN: an installed copy with no home and no registered repo yet must wait
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "workspace-event-loop-lag: a restart with many live sessions asks tmux once for every live pane and restores in slices after the server is listening (session-restore).",
|
|
3
|
+
"modules": ["session-restore.test.js"],
|
|
4
|
+
"seconds": { "session-restore.test.js": 12 }
|
|
5
|
+
}
|