create-pmos 0.234.0 → 0.236.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 +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/bin/pmos.js +18 -0
- package/workspace/c3-report-design-test.js +20 -3
- package/workspace/guide/report-design.md +1 -1
- package/workspace/lib/win-command.js +54 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-rd.js +3 -3
- package/workspace/public/pm/c3-design-studio.js +43 -15
- package/workspace/public/pm/c3-report-deck.js +11 -7
- package/workspace/replaced-tests.json +14 -0
- package/workspace/server.js +5 -2
- package/workspace/test-lists/one-line-setup.json +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.236.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
|
@@ -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-14T10:47:28Z"}
|
|
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-14T10:47:28Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -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-14T10:47:28Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/bin/pmos.js
CHANGED
|
@@ -27,6 +27,7 @@ const http = require('http');
|
|
|
27
27
|
const net = require('net');
|
|
28
28
|
const fs = require('fs');
|
|
29
29
|
const path = require('path');
|
|
30
|
+
const os = require('os');
|
|
30
31
|
const { spawn, spawnSync } = require('child_process');
|
|
31
32
|
const D = require('../lib/server-discovery');
|
|
32
33
|
const stateRoot = require('../lib/state-root');
|
|
@@ -164,6 +165,17 @@ function repoRootOf(dir) {
|
|
|
164
165
|
return top && fs.existsSync(top) ? top : path.resolve(dir);
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
// `pmos` typed in a new terminal runs in the home folder. Registering the home folder as a workspace made PMOS start
|
|
169
|
+
// its background jobs over every file the PM has. The setup's PMOS folder (PMOS_DIR, else ~/PMOS) is opened instead;
|
|
170
|
+
// with no such folder, `pmos` says where to run it and registers nothing.
|
|
171
|
+
function workspaceDir(dir) {
|
|
172
|
+
const home = path.resolve(os.homedir());
|
|
173
|
+
if (path.resolve(repoRootOf(dir)) !== home) return { dir };
|
|
174
|
+
const pmosDir = process.env.PMOS_DIR ? path.resolve(process.env.PMOS_DIR) : path.join(home, 'PMOS');
|
|
175
|
+
if (pmosDir !== home && fs.existsSync(pmosDir)) return { dir: pmosDir, note: `note: this is your home folder, so PMOS opens your PMOS folder, ${pmosDir}.` };
|
|
176
|
+
return { refused: 'this is your home folder. Run `pmos` inside a project folder, or `pmos <folder>`.' };
|
|
177
|
+
}
|
|
178
|
+
|
|
167
179
|
function openBrowser(url) {
|
|
168
180
|
if (NO_OPEN) return;
|
|
169
181
|
const cmd = process.platform === 'darwin' ? ['open', [url]]
|
|
@@ -283,6 +295,12 @@ async function servesTailnet(port) {
|
|
|
283
295
|
// `remote` starts a NEW server in tailnet mode (pmos phone); a server that is already running is reused as it is.
|
|
284
296
|
// `open: false` registers the repo without opening the desktop browser.
|
|
285
297
|
async function up(dir, { remote = false, open = true, waited = false } = {}) {
|
|
298
|
+
if (!waited) {
|
|
299
|
+
const where = workspaceDir(dir);
|
|
300
|
+
if (where.refused) { say(`✗ ${where.refused}`); return 1; }
|
|
301
|
+
if (where.note) say(where.note);
|
|
302
|
+
dir = where.dir;
|
|
303
|
+
}
|
|
286
304
|
// 1. Is a workspace already running — on ANY port it recorded, not just the one this shell guesses?
|
|
287
305
|
let live = await discover();
|
|
288
306
|
// A server that predates this install is running OLD code under NEW files (or the reverse). Reusing
|
|
@@ -138,16 +138,33 @@ const DOC = 'planning/reports/rs1/2026-10-21.md';
|
|
|
138
138
|
await page.click('[data-rd-act="mksave"]');
|
|
139
139
|
ok('the marker is numbered on the preview and listed under Point and say', !!(await until(() => page.$('[data-rd-mk]'))) && (await page.$$('[data-rd-mkrow]')).length === 1 && /Make this title smaller/.test(await text('[data-rd-point]')));
|
|
140
140
|
await shot(page, 'design-point-1440');
|
|
141
|
+
/* the slide and its thumbnails are not rebuilt by a paint: a frame put back in the page loads again, and that was the flicker */
|
|
142
|
+
const tagFrames = () => page.$$eval('[data-rd-preview] iframe', (fs) => fs.forEach((f) => { f.__kept = 1; }));
|
|
143
|
+
const framesKept = () => page.$$eval('[data-rd-preview] iframe', (fs) => fs.length > 2 && fs.every((f) => f.__kept === 1));
|
|
144
|
+
await tagFrames();
|
|
141
145
|
await page.fill('[data-rd-in]', 'Make it dense');
|
|
142
146
|
await page.click('[data-rd-act="send"]');
|
|
143
147
|
const turn = await until(() => sent('/api/report-design/turn')[0]);
|
|
144
148
|
ok('a turn posts the words and the markers as points', !!turn && turn.body.text === 'Make it dense' && Array.isArray(turn.body.points) && turn.body.points.length === 1 && turn.body.points[0].text === 'Make this title smaller' && turn.body.points[0].n === 1 && typeof turn.body.points[0].slide === 'string', JSON.stringify(turn && turn.body));
|
|
145
149
|
ok('while the job runs the conversation shows PMOS working', !!(await until(() => page.$('[data-rd-running]'), 3000)));
|
|
146
|
-
|
|
150
|
+
await page.$eval('[data-rd-in]', (t) => { t.__kept = 1; });
|
|
151
|
+
await page.type('[data-rd-in]', 'next');
|
|
152
|
+
await sleep(3200);
|
|
153
|
+
ok('while the job runs the slide and its thumbnails stay in place: the reads of the design rebuild no frame', await framesKept());
|
|
154
|
+
ok('while the job runs the box you type in is not rebuilt and keeps your words', await page.$eval('[data-rd-in]', (t) => t.__kept === 1 && t.value === 'next'));
|
|
155
|
+
await page.fill('[data-rd-in]', '');
|
|
156
|
+
ok('the job lands D4 as the current version, with its PMOS line', !!(await until(async () => await page.getAttribute('[data-rd-v="4"]', 'aria-pressed') === 'true', 15000)) && /D4:/.test(await text('[data-rd-chat]')));
|
|
147
157
|
ok('the markers are cleared after the turn', (await page.$$('[data-rd-mkrow]')).length === 0);
|
|
158
|
+
await until(() => page.$('[data-rd-sstrip] [data-rd-sgo="1"]'));
|
|
159
|
+
await tagFrames();
|
|
160
|
+
const bigBefore = await page.getAttribute('[data-rd-sbig]', 'data-rd-sbig');
|
|
161
|
+
await page.click('[data-rd-sstrip] [data-rd-sgo="1"]');
|
|
162
|
+
ok('picking a thumbnail changes the slide and keeps the thumbnails as they were', !!(await until(async () => (await page.getAttribute('[data-rd-sbig]', 'data-rd-sbig')) !== bigBefore))
|
|
163
|
+
&& await page.getAttribute('[data-rd-sgo="1"]', 'aria-current') === 'true' && await page.$$eval('[data-rd-sstrip] iframe', (fs) => fs.length > 1 && fs.every((f) => f.__kept === 1)));
|
|
164
|
+
await page.click('[data-rd-sstrip] [data-rd-sgo="0"]');
|
|
148
165
|
await page.fill('[data-rd-in]', 'This turn should fail');
|
|
149
166
|
await page.click('[data-rd-act="send"]');
|
|
150
|
-
ok('a failed job shows an error line and makes no version', !!(await until(() => page.$('[data-rd-log="error"]'),
|
|
167
|
+
ok('a failed job shows an error line and makes no version', !!(await until(() => page.$('[data-rd-log="error"]'), 15000)) && (await page.$$('[data-rd-v]')).length === 4);
|
|
151
168
|
|
|
152
169
|
/* compare (#design-compare) */
|
|
153
170
|
await page.click('[data-rd-act="cmp"]');
|
|
@@ -185,7 +202,7 @@ const DOC = 'planning/reports/rs1/2026-10-21.md';
|
|
|
185
202
|
await page.click('[data-rd-act="send"]');
|
|
186
203
|
const desc = await until(() => sent('/api/report-design/describe')[0]);
|
|
187
204
|
ok('the first words are posted as the description with the format, then a turn', !!desc && desc.body.id === 'rg0' && /decisions first/.test(desc.body.text) && desc.body.format === 'deck' && !!(await until(() => sent('/api/report-design/turn').some((t) => t.body.id === 'rg0'))));
|
|
188
|
-
ok('D1 arrives', !!(await until(() => page.$('[data-rd-v="1"]'),
|
|
205
|
+
ok('D1 arrives', !!(await until(() => page.$('[data-rd-v="1"]'), 15000)));
|
|
189
206
|
await closeAll(page);
|
|
190
207
|
|
|
191
208
|
/* a document design (#design-doc), then This is it */
|
|
@@ -23,7 +23,7 @@ Use one of these two buttons:
|
|
|
23
23
|
1. Select **Deck** or **Document** at the top of the studio.
|
|
24
24
|
2. Write what the report is for and who reads it. For example: "This goes to the progress session every other Thursday. The decisions must stand out."
|
|
25
25
|
3. Press **Add a sample** to give PMOS a report that people liked. You can upload a file, paste a link, add a page from Chrome (see below), or pick a report document that PMOS has. Each sample shows what PMOS took from it. PMOS reads a link from Google Slides, Docs or Sheets, or from Confluence, through your own connectors. It saves the words and the structure (slide titles, order, sections and tables), and it changes nothing in the file. It does not get the look. To match the look, also add the file as a PDF or a screenshot. For Google Slides, choose **File**, **Download**, **PDF document (.pdf)** in Slides, then add that file. If your connectors cannot read the link, the sample says so. Any other link is kept by its address, and PMOS cannot open it. PMOS sees the pages of a PDF or an image, so it can follow their look. From a .pptx or a .docx, PMOS gets only the words.
|
|
26
|
-
4. Press **Make the next version**. PMOS makes a version from your words and this cycle's data. This takes a short time. You can leave the studio. The studio shows when the version is ready.
|
|
26
|
+
4. Press **Make the next version**. PMOS makes a version from your words and this cycle's data. This takes a short time. You can leave the studio. The studio shows when the version is ready. While PMOS works, the version on screen and its slides stay as they are, and you can keep reading it or write your next turn.
|
|
27
27
|
5. Read the version in the preview. Write what to change and press **Make the next version** again. Each turn makes a new version: D1, D2, D3 and so on.
|
|
28
28
|
|
|
29
29
|
To set the width of the studio, use **Half**, **3/4** or **Whole** at the top right. PMOS keeps your choice on this computer.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* lib/win-command.js — how to start a command such as `claude` on Windows.
|
|
3
|
+
*
|
|
4
|
+
* On Windows, Node starts a program by name only when it is an .exe or .com file. A command that npm installed
|
|
5
|
+
* (`npm install -g @anthropic-ai/claude-code`) is a `claude.cmd` file, so `spawn('claude')` fails with ENOENT
|
|
6
|
+
* (-4058), even though the same `claude` works in a terminal. Since Node 20, Node also refuses to start a .cmd
|
|
7
|
+
* file without a shell.
|
|
8
|
+
*
|
|
9
|
+
* resolve() finds the file on PATH the way a terminal does (PATHEXT) and returns what to start instead:
|
|
10
|
+
* - an .exe or .com file: its full path;
|
|
11
|
+
* - an npm .cmd shim: node and the script the shim points to, so no shell quoting is involved;
|
|
12
|
+
* - any other .cmd or .bat file: cmd.exe /d /s /c with the file, as a last resort.
|
|
13
|
+
* On other platforms, and when nothing is found, the command is returned unchanged, so the caller's own
|
|
14
|
+
* "not found" handling still applies.
|
|
15
|
+
*
|
|
16
|
+
* Pure apart from the injected `exists` and `read`, so every case is testable on any platform.
|
|
17
|
+
*/
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
|
|
21
|
+
function resolve(cmd, {
|
|
22
|
+
platform = process.platform, env = process.env, execPath = process.execPath,
|
|
23
|
+
exists = (f) => { try { return fs.statSync(f).isFile(); } catch { return false; } },
|
|
24
|
+
read = (f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } },
|
|
25
|
+
} = {}) {
|
|
26
|
+
const same = { file: cmd, prefix: [] };
|
|
27
|
+
if (platform !== 'win32' || !cmd) return same;
|
|
28
|
+
const P = path.win32;
|
|
29
|
+
const exts = String(env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean).map((e) => e.toLowerCase());
|
|
30
|
+
const hasExt = exts.includes(P.extname(cmd).toLowerCase());
|
|
31
|
+
const names = hasExt ? [cmd] : exts.map((e) => cmd + e);
|
|
32
|
+
const dirs = /[\\/]/.test(cmd) ? [''] : String(env.PATH || env.Path || '').split(';').filter(Boolean);
|
|
33
|
+
let found = null;
|
|
34
|
+
for (const d of dirs) {
|
|
35
|
+
for (const n of names) { const f = d ? P.join(d, n) : n; if (exists(f)) { found = f; break; } }
|
|
36
|
+
if (found) break;
|
|
37
|
+
}
|
|
38
|
+
if (!found) return same;
|
|
39
|
+
const ext = P.extname(found).toLowerCase();
|
|
40
|
+
if (ext === '.exe' || ext === '.com') return { file: found, prefix: [] };
|
|
41
|
+
if (ext === '.cmd' || ext === '.bat') {
|
|
42
|
+
// npm's cmd-shim names its target relative to the shim's folder: "%dp0%\node_modules\…\cli.js" (or "%~dp0\…").
|
|
43
|
+
const text = read(found) || '';
|
|
44
|
+
const m = /"%~?dp0%?\\([^"%]+\.(js|cjs|mjs|exe))"/i.exec(text);
|
|
45
|
+
if (m) {
|
|
46
|
+
const target = P.join(P.dirname(found), m[1]);
|
|
47
|
+
if (exists(target)) return m[2].toLowerCase() === 'exe' ? { file: target, prefix: [] } : { file: execPath, prefix: [target] };
|
|
48
|
+
}
|
|
49
|
+
return { file: env.ComSpec || 'cmd.exe', prefix: ['/d', '/s', '/c', found] };
|
|
50
|
+
}
|
|
51
|
+
return same;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { resolve };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.236.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.236.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* The data is the mockup's: the MySPH & Identity Platform biweekly update (set-up rs1) has a saved deck design, versions D1 to D3 made from
|
|
8
8
|
* the Wed 21 Oct draft, two samples, a conversation, two designed cycles (Wed 21 Oct differs: Usage-based billing got its own slide; Wed 7 Oct
|
|
9
9
|
* matches), speaker notes carried forward from 7 Oct and four notes on the slides. The Monthly OOH x Digital review (rs3) has a document
|
|
10
|
-
* design in progress (D1, D2, not saved). Any other set-up starts with no design. A turn runs
|
|
10
|
+
* design in progress (D1, D2, not saved). Any other set-up starts with no design. A turn runs 4 seconds and lands the next version; a turn
|
|
11
11
|
* whose words say "fail" lands an error line instead. The renders follow the HTML contract (one <style>, pmos-slide or pmos-sec sections, the
|
|
12
12
|
* data attributes); their colours are the slide tokens the frame links. Nothing is saved anywhere and nothing is sent. */
|
|
13
13
|
|
|
@@ -249,9 +249,9 @@ function versionHtml(id, D, v) {
|
|
|
249
249
|
const s = ctx.setups().find((x) => x.id === id) || { name: id }; const d = curDoc(id);
|
|
250
250
|
return genericHtml(v.format, s.name, sectionsOf(d ? ctx.docText(d.path) : ''), v.step || 1);
|
|
251
251
|
}
|
|
252
|
-
/* a job lands when its
|
|
252
|
+
/* a job lands when its 4 seconds are up (read on each GET): the studio reads the design more than once while it runs, as with a real job */
|
|
253
253
|
function settle(id) {
|
|
254
|
-
const D = DESIGNS[id]; if (!D || !D.running || performance.now() - D.running.t0 <
|
|
254
|
+
const D = DESIGNS[id]; if (!D || !D.running || performance.now() - D.running.t0 < 4000) return;
|
|
255
255
|
const t = D.running.turn; const pts = D.running.points || []; D.running = null;
|
|
256
256
|
if (/\bfail\b/i.test(t)) { D.log.push({ at: nowIso(), who: 'pmos', kind: 'error', text: 'The job ended before it made a version: the render did not pass the check (a slide had no data-slide-id). Nothing changed. Try the turn again.' }); return; }
|
|
257
257
|
const base = D.versions.find((x) => x.n === D.current) || null; const n = (D.versions.length ? Math.max(...D.versions.map((x) => x.n)) : 0) + 1;
|
|
@@ -43,6 +43,16 @@ function sampleChip(s) {
|
|
|
43
43
|
: el('p', null, el('span', { class: 'dim' }, 'PMOS took: '), s.took || 'PMOS reads it on the next turn.')));
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/* put() that leaves a node in place when it stays: only the nodes that go are taken out, so a kept frame does not load again */
|
|
47
|
+
function keep(host, ...kids) {
|
|
48
|
+
const want = kids.flat(Infinity).filter((k) => k != null && k !== false && k !== true).map((k) => (typeof k === 'string' ? document.createTextNode(k) : k));
|
|
49
|
+
const set = new Set(want);
|
|
50
|
+
for (const c of [...host.childNodes]) if (!set.has(c)) c.remove();
|
|
51
|
+
let ref = host.firstChild;
|
|
52
|
+
for (const n of want) { if (n === ref) ref = ref.nextSibling; else host.insertBefore(n, ref); }
|
|
53
|
+
return host;
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
/* ── the studio ── */
|
|
47
57
|
export function openStudio({ ws, id, name, view, onClose } = {}) {
|
|
48
58
|
ensureDeckCss();
|
|
@@ -59,16 +69,21 @@ export function openStudio({ ws, id, name, view, onClose } = {}) {
|
|
|
59
69
|
sizeBtns.forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.rdSize === size)));
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
|
|
72
|
+
/* follow: show the current version when it changed (a job landed). A read that changed nothing paints nothing: the poll reads the design
|
|
73
|
+
* every 1.5 seconds while a job runs, and a repaint rebuilt the conversation and the box the PM types in. */
|
|
74
|
+
async function load(keepView, follow) {
|
|
63
75
|
const r = await RD.design(ws, id);
|
|
64
|
-
if (!r || r.ok === false) { S.err = (r && r.error) || 'The design could not be read.'; paint(); return; }
|
|
76
|
+
if (!r || r.ok === false) { S.err = (r && r.error) || 'The design could not be read.'; S.shown = null; paint(); return; }
|
|
77
|
+
const prev = S.D ? S.D.current : null;
|
|
65
78
|
S.D = r.design; S.setup = r.setup || { id, name };
|
|
66
79
|
if (!K.fmt || !S.D.versions.length) K.fmt = S.D.format || 'deck';
|
|
67
|
-
if (!keepView || K.view == null || !S.D.versions.some((v) => v.n === K.view)) K.view = S.D.current;
|
|
80
|
+
if (!keepView || K.view == null || !S.D.versions.some((v) => v.n === K.view) || (follow && S.D.current !== prev)) K.view = S.D.current;
|
|
68
81
|
if (S.D.running || (S.D.samples || []).some((x) => x.fetching)) poll(); else clearTimeout(S.timer);
|
|
69
82
|
if (K.view != null) await render(K.view);
|
|
70
83
|
if (K.cmp) await render(cmpA());
|
|
71
|
-
|
|
84
|
+
const shown = JSON.stringify([r.design, r.setup, K.view, K.cmp, K.cmp ? cmpA() : null, Object.keys(S.cache)]);
|
|
85
|
+
if (shown === S.shown) return;
|
|
86
|
+
S.shown = shown; paint();
|
|
72
87
|
}
|
|
73
88
|
async function render(n) {
|
|
74
89
|
if (n == null || S.cache[n]) return;
|
|
@@ -84,7 +99,11 @@ export function openStudio({ ws, id, name, view, onClose } = {}) {
|
|
|
84
99
|
}
|
|
85
100
|
S.watch = setInterval(checkNew, 4000);
|
|
86
101
|
window.addEventListener('focus', checkNew);
|
|
87
|
-
function poll() { clearTimeout(S.timer); S.timer = setTimeout(async () => { if (S.closed) return; const was = S.D && S.D.current; await load(
|
|
102
|
+
function poll() { clearTimeout(S.timer); S.timer = setTimeout(async () => { if (S.closed) return; const was = S.D && S.D.current; await load(true, true); if (S.D && !S.D.running && S.D.current !== was) { K.markers = []; K.draft = null; scrollLog(); } }, 1500); }
|
|
103
|
+
/* The preview's frames are kept from one paint to the next: a frame that is taken out of the page and put back loads again, and every
|
|
104
|
+
* paint rebuilt the slide and its thumbnails, so they flickered on each read while a job ran and on each click. */
|
|
105
|
+
const KEPT = {};
|
|
106
|
+
const reuse = (slot, key, make) => { const k = KEPT[slot]; if (k && k.key === key) return k.node; const node = make(); KEPT[slot] = { key, node }; return node; };
|
|
88
107
|
const ver = (n) => (S.D.versions || []).find((v) => v.n === n);
|
|
89
108
|
const cmpA = () => { const b = S.D.current; const a = K.cmpA != null && K.cmpA !== b && ver(K.cmpA) ? K.cmpA : (S.D.versions.filter((v) => v.n < b).pop() || S.D.versions.find((v) => v.n !== b) || {}).n; return a; };
|
|
90
109
|
const scrollLog = () => requestAnimationFrame(() => { const l = body.querySelector('.rd-dks-log'); if (l) l.scrollTop = l.scrollHeight; });
|
|
@@ -216,19 +235,26 @@ export function openStudio({ ws, id, name, view, onClose } = {}) {
|
|
|
216
235
|
if (!R) return [strip(), capline, el('div', { class: 'fold', role: 'status' }, icon('clock', 'sm'), ' Reading D' + v.n + '.')];
|
|
217
236
|
if (R.err) return [strip(), capline, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), ' D' + v.n + ' could not be read: ' + R.err + '.')];
|
|
218
237
|
if (R.format === 'doc') {
|
|
219
|
-
const card =
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
238
|
+
const card = reuse('doc', v.n, () => {
|
|
239
|
+
const c = el('div', { class: 'rd-docf rd-dks-doc', 'data-rd-sbig': 'doc' }); const layer = el('div', { class: 'rd-doclayer' });
|
|
240
|
+
const box = (e) => { const sec = e.target.closest && e.target.closest('section.pmos-sec'); if (!sec) return null; const r = sec.getBoundingClientRect(); return { id: sec.getAttribute('data-sec-id'), x: (e.clientX - r.left) / r.width * 100, y: (e.clientY - r.top) / r.height * 100 }; };
|
|
241
|
+
c.append(frame(R.style, R.body, { title: 'D' + v.n + ', the document', cls: 'pmos-doc', focusable: true, onLoad: (doc, fr) => {
|
|
242
|
+
const fit = () => { if (!doc.documentElement) return; fr.style.setProperty('--rp-h', Math.max(200, doc.documentElement.scrollHeight) + 'px'); const L = []; for (const s of R.secs) { const n = doc.querySelector('section.pmos-sec[data-sec-id="' + s.id.replace(/"/g, '') + '"]'); if (n) L.push(...markers(s.id, n.getBoundingClientRect())); } put(layer, ...L); };
|
|
243
|
+
c.fit = fit; fit(); setTimeout(fit, 120); wireFrame(doc, { ...pointHands(box), more: fit });
|
|
244
|
+
} }), layer);
|
|
245
|
+
return c;
|
|
246
|
+
});
|
|
247
|
+
card.classList.toggle('placing', !!K.point); if (card.fit) card.fit();
|
|
225
248
|
const key = K.draft ? K.draft.slide : (K.markers[0] || {}).slide || (R.secs[0] || {}).id || 'doc';
|
|
226
249
|
return [strip(), capline, run, card, pointSect(key)];
|
|
227
250
|
}
|
|
228
251
|
if (!R.slides.length) return [strip(), capline, el('div', { class: 'fold w' }, icon('alert', 'sm'), ' D' + v.n + ' has no slide.')];
|
|
229
252
|
K.sidx = Math.min(K.sidx, R.slides.length - 1); const s = R.slides[K.sidx];
|
|
230
|
-
const big =
|
|
231
|
-
|
|
253
|
+
const big = reuse('big', v.n + '/' + s.id, () => slideCard(R, s, { cls: 'rd-big', attrs: { 'data-rd-sbig': s.id }, focusable: true, wire: (doc) => wireFrame(doc, pointHands((e) => ({ id: s.id, x: e.clientX / 1280 * 100, y: e.clientY / 720 * 100 }))) }));
|
|
254
|
+
big.classList.toggle('placing', !!K.point); [...big.children].forEach((c) => { if (c.tagName !== 'IFRAME') c.remove(); }); big.append(...markers(s.id));
|
|
255
|
+
const thumbs = reuse('thumbs', v.n, () => thumbStrip(R, K.sidx, (i) => { K.sidx = i; K.draft = null; paint(); }));
|
|
256
|
+
thumbs.querySelectorAll('[data-rd-sgo]').forEach((b) => b.setAttribute('aria-current', String(Number(b.getAttribute('data-rd-sgo')) === K.sidx)));
|
|
257
|
+
return [strip(), capline, run, thumbs,
|
|
232
258
|
el('div', { class: 'rd-dk-cap' }, el('b', { 'data-rd-scap': '' }, 'Slide ' + s.n + ' of ' + R.slides.length + ': ' + s.title), el('span', { class: 'dim rd-small' }, hint(s.kind))), big, pointSect(s.id)];
|
|
233
259
|
}
|
|
234
260
|
function thumbStrip(R, cur, pick) {
|
|
@@ -277,13 +303,15 @@ export function openStudio({ ws, id, name, view, onClose } = {}) {
|
|
|
277
303
|
const D = S.D; const nm = (S.setup && S.setup.name) || name || 'Report';
|
|
278
304
|
const sb = body.closest('.sb'); const y = sb ? sb.scrollTop : 0; const log = body.querySelector('.rd-dks-log'); const ly = log ? log.scrollTop : null;
|
|
279
305
|
const focus = document.activeElement && body.contains(document.activeElement) && document.activeElement.matches('[data-rd-in],[data-rd-link]') ? document.activeElement.getAttribute('data-rd-in') != null ? '[data-rd-in]' : '[data-rd-link]' : null;
|
|
280
|
-
|
|
306
|
+
S.dks = S.dks || el('div', { class: 'rd-dks' }); S.out = S.out || el('div', { class: 'rd-dks-out', 'data-rd-preview': '' });
|
|
307
|
+
keep(body, el('div', { class: 'rd-dk-bar' }, el('div', { class: 'chips2 rd-wrap' }, el('div', { class: 'seg', role: 'group', 'aria-label': 'Report type' },
|
|
281
308
|
btn({ 'data-rd-fmt': 'deck', 'aria-pressed': String(K.fmt === 'deck'), onclick: () => setFmt('deck') }, icon('img', 'sm'), 'Deck'), btn({ 'data-rd-fmt': 'doc', 'aria-pressed': String(K.fmt === 'doc'), onclick: () => setFmt('doc') }, icon('doc', 'sm'), 'Document')),
|
|
282
309
|
el('b', null, nm), D.saved ? el('span', { class: 'chip2 ds-good', 'data-rd-saved': String(D.saved.version) }, icon('check', 'sm'), 'Saved design: D' + D.saved.version) : el('span', { class: 'chip2', 'data-rd-saved': '' }, 'Not saved yet')),
|
|
283
310
|
el('div', { class: 'chips2' }, pbtn('primary', 'This is it', 'check', { 'data-rd-act': 'accept', disabled: !D.versions.length, onclick: () => accept() }),
|
|
284
311
|
D.saved ? pbtn('ghost', 'Design brief', 'doc', { 'data-rd-act': 'brief', onclick: () => openBrief({ ws, id, name: nm }) }) : null)),
|
|
285
312
|
el('div', { class: 'fold' }, icon('info', 'sm'), ' No template to fill in. Describe the report, add samples, then refine until it looks right. PMOS keeps what you settle on and drafts every cycle to match it. Nothing is sent.'),
|
|
286
|
-
|
|
313
|
+
S.dks);
|
|
314
|
+
keep(S.dks, chat(), S.out); keep(S.out, preview());
|
|
287
315
|
if (sb) sb.scrollTop = y; const l2 = body.querySelector('.rd-dks-log'); if (l2) l2.scrollTop = ly == null ? l2.scrollHeight : ly;
|
|
288
316
|
if (focus) { const n = body.querySelector(focus); if (n) n.focus(); }
|
|
289
317
|
}
|
|
@@ -199,21 +199,25 @@ export function createDeck(o) {
|
|
|
199
199
|
const root = el('div', { class: 'rd-dk', 'data-rd-deck': path });
|
|
200
200
|
const redraw = () => { if (S.api && S.api.redraw) S.api.redraw(); };
|
|
201
201
|
|
|
202
|
+
/* A read that changed nothing draws nothing: while the cycle is redrawn the deck is read every 2 seconds, and a redraw loads every frame
|
|
203
|
+
* again, so the slides flickered. */
|
|
202
204
|
async function load() {
|
|
203
205
|
const c = await RD.cycle(ws, path);
|
|
204
|
-
if (!c || c.ok === false) { S.loaded = true; S.cyc = null; S.err = (c && c.error) || ''; redraw(); return; }
|
|
205
|
-
S.cyc = c;
|
|
206
|
+
if (!c || c.ok === false) { S.loaded = true; S.cyc = null; S.err = (c && c.error) || ''; S.seen = null; redraw(); return; }
|
|
207
|
+
S.cyc = c; const seen = [c];
|
|
206
208
|
if (c.designed) {
|
|
207
|
-
try {
|
|
208
|
-
const n = await RD.notes(ws, path); if (n && n.ok !== false) S.notes = { speaker: n.speaker || {}, suggestions: n.suggestions || {}, pins: n.pins || [] };
|
|
209
|
+
try { const h = await RD.cycleHtml(ws, path); seen.push(h); S.R = splitRender(h); } catch (e) { S.err = (e && e.message) || 'the render could not be read'; S.R = null; }
|
|
210
|
+
const n = await RD.notes(ws, path); seen.push(n); if (n && n.ok !== false) S.notes = { speaker: n.speaker || {}, suggestions: n.suggestions || {}, pins: n.pins || [] };
|
|
209
211
|
if (c.previous && c.previous.path) {
|
|
210
|
-
const pc = await RD.cycle(ws, c.previous.path);
|
|
211
|
-
if (pc && pc.ok !== false && pc.designed) { S.prev = { ...pc, ...c.previous }; try {
|
|
212
|
+
const pc = await RD.cycle(ws, c.previous.path); seen.push(pc);
|
|
213
|
+
if (pc && pc.ok !== false && pc.designed) { S.prev = { ...pc, ...c.previous }; try { const h = await RD.cycleHtml(ws, c.previous.path); seen.push(h); S.prevR = splitRender(h); } catch { S.prevR = null; } }
|
|
212
214
|
}
|
|
213
215
|
}
|
|
214
216
|
S.loaded = true; S.cur = Math.min(S.cur, Math.max(0, count() - 1));
|
|
215
217
|
if (c.rendering) poll();
|
|
216
|
-
|
|
218
|
+
const sig = JSON.stringify(seen) + S.err;
|
|
219
|
+
if (sig === S.seen) return;
|
|
220
|
+
S.seen = sig; redraw();
|
|
217
221
|
}
|
|
218
222
|
function poll() { clearTimeout(S.timer); S.timer = setTimeout(async () => { if (!root.isConnected && !OV) return; await load(); }, 2000); }
|
|
219
223
|
const count = () => (S.R ? (S.R.format === 'deck' ? S.R.slides.length : S.R.secs.length) : 0);
|
|
@@ -3061,6 +3061,20 @@
|
|
|
3061
3061
|
"names": [
|
|
3062
3062
|
"hand to agent: no drop on any column started a session or called a /handoff/ route"
|
|
3063
3063
|
]
|
|
3064
|
+
},
|
|
3065
|
+
{
|
|
3066
|
+
"id": "E75",
|
|
3067
|
+
"group": "E",
|
|
3068
|
+
"file": "workspace/pm-r12-test.js",
|
|
3069
|
+
"section": "T3: the two spawn paths",
|
|
3070
|
+
"change": "rewritten",
|
|
3071
|
+
"slice": "one-line-setup",
|
|
3072
|
+
"reason": "On Windows, Node cannot start npm's claude.cmd by name (spawn claude ENOENT, -4058). The direct-PTY spawn now starts the file lib/win-command.js resolves (unchanged on macOS and Linux). The assertion read the old line of server.js literally.",
|
|
3073
|
+
"replacement": "The assertion stays under the same name; it now matches pty.spawn(run.file, [...run.prefix, ...launchArgs], …), which still passes the launch args as an argv.",
|
|
3074
|
+
"pm_approval": null,
|
|
3075
|
+
"names": [
|
|
3076
|
+
"T3: both spawn paths take the launch args — tmux joins plain tokens, the direct PTY passes an argv"
|
|
3077
|
+
]
|
|
3064
3078
|
}
|
|
3065
3079
|
]
|
|
3066
3080
|
}
|
package/workspace/server.js
CHANGED
|
@@ -1957,6 +1957,7 @@ const autoSync = require('./lib/auto-sync').createAutoSync({
|
|
|
1957
1957
|
// workspace starts the board and facts rebuilds when the repository changes, so nothing is re-read on a clock. The board
|
|
1958
1958
|
// keeps its last value while it rebuilds (stale while revalidate): dropping it would show a blank Board on every change.
|
|
1959
1959
|
const factsAsync = require('./lib/facts-async.js');
|
|
1960
|
+
const winCommand = require('./lib/win-command');
|
|
1960
1961
|
factsAsync.configure({ stateDir: STATE });
|
|
1961
1962
|
const freshness = require('./lib/freshness.js').create({
|
|
1962
1963
|
stateDir: STATE,
|
|
@@ -3611,7 +3612,9 @@ function startHeadless(base, workdir, kind, launchArgs, env, initialInput, addDi
|
|
|
3611
3612
|
let child;
|
|
3612
3613
|
// detached: its own process group, so stopping it also stops what it started (an MCP server, a script) and no
|
|
3613
3614
|
// grandchild keeps the output pipes open after the run is stopped.
|
|
3614
|
-
|
|
3615
|
+
// Windows: `claude` installed by npm is claude.cmd, which spawn() cannot start by name (lib/win-command.js).
|
|
3616
|
+
const run = winCommand.resolve(base.agent, { env });
|
|
3617
|
+
try { child = spawn(run.file, [...run.prefix, ...args], { cwd: workdir, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }); }
|
|
3615
3618
|
catch (e) {
|
|
3616
3619
|
s.state = 'exited'; s.note = spawnFailureAdvice(e, base.agent, false);
|
|
3617
3620
|
say(`\x1b[38;5;203m✗ ${s.note}\x1b[0m\n`); persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
@@ -3862,7 +3865,7 @@ function spawnSessionInner({ name, agent, cwd, ws, origin, initialInput, profile
|
|
|
3862
3865
|
let p, usedCmd = cmd;
|
|
3863
3866
|
try {
|
|
3864
3867
|
if (isShell) { const r = spawnPty(cmd, workdir, penv); p = r.pty; usedCmd = r.cmd; }
|
|
3865
|
-
else p = pty.spawn(
|
|
3868
|
+
else { const run = winCommand.resolve(cmd, { env: penv }); p = pty.spawn(run.file, [...run.prefix, ...launchArgs], { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: penv }); }
|
|
3866
3869
|
} catch (e) {
|
|
3867
3870
|
return exitedSession(base, workdir, spawnFailureAdvice(e, cmd, isShell));
|
|
3868
3871
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "one-line-setup: on Windows the
|
|
3
|
-
"modules": ["win-hide.test.js", "busy-server.test.js"],
|
|
4
|
-
"seconds": { "win-hide.test.js": 1, "busy-server.test.js": 12 }
|
|
2
|
+
"_comment": "one-line-setup: on Windows the server hides command windows (win-hide), starts npm's claude.cmd (win-command), and `pmos` waits for its own busy server (busy-server) and never registers the home folder (windows-start).",
|
|
3
|
+
"modules": ["win-hide.test.js", "busy-server.test.js", "windows-start.test.js"],
|
|
4
|
+
"seconds": { "win-hide.test.js": 1, "busy-server.test.js": 12, "windows-start.test.js": 3 }
|
|
5
5
|
}
|