create-pmos 0.233.0 → 0.235.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.233.0",
3
+ "version": "0.235.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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@c113f92
1
+ kernel-source: pmos@a744ae0
2
2
  assembled: 2026-10-14
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-14T09:17:36Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T09:56:46Z"}
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-14T09:17:36Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T09:56:46Z"}
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-14T09:17:36Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-14T09:56:46Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -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
@@ -11,7 +11,8 @@
11
11
  * meetings came in with the morning brief saw them under Record and nowhere else. This reader joins the three. The Record menu reads it
12
12
  * too (todayOf, GET /api/calendar-today): it read the Agenda once, so an imported meeting was on the Calendar and missing under Record. An imported event wins
13
13
  * over a remembered one with the same start minute and title, because it carries the PM's notes; today's brief replaces the remembered
14
- * events of today, as agendaMonth does.
14
+ * events of today, as agendaMonth does. Two imported copies of one meeting (same start minute and title, different ids) are one
15
+ * event (oneEach), so the Calendar does not draw a meeting twice side by side.
15
16
  *
16
17
  * `at` and `end` of a remembered event are local date-times with the offset (2026-10-12T10:45:00+08:00), so a reader that takes the
17
18
  * first ten characters as the day (calendar-model.js bucketEvents) gets the local day. READ ONLY; a file that cannot be read gives nothing. */
@@ -52,6 +53,32 @@ function asEvent(e, src) {
52
53
  ...(e.series ? { series: String(e.series) } : {}) };
53
54
  }
54
55
 
56
+ /** One imported event per meeting. The ledger folds by event id, and the id is whatever the agent session that read the calendar
57
+ * gave (calendar-sync.skill): one run can give the calendar's event id and the next its iCal UID, or the same meeting can come in
58
+ * from two calendars. Each copy was then a meeting of its own, and the Calendar drew the two side by side. Copies with the same
59
+ * start minute and title are one meeting: the copy read last is kept (its title and times are the newest), and the PM's notes of
60
+ * every copy stay on it. Events with no readable start are kept as they are. Pure. */
61
+ function oneEach(imported) {
62
+ const by = new Map();
63
+ const loose = [];
64
+ imported.forEach((e, i) => {
65
+ const s = e && e.at ? Date.parse(e.at) : NaN;
66
+ if (!e || Number.isNaN(s)) { if (e) loose.push(e); return; }
67
+ const k = minuteKey(s, e.title);
68
+ const prev = by.get(k);
69
+ if (!prev) { by.set(k, { e, i }); return; }
70
+ const newer = String(e.fetched_at || '') > String(prev.e.fetched_at || '') || (String(e.fetched_at || '') === String(prev.e.fetched_at || '') && i > prev.i);
71
+ const keep = newer ? e : prev.e;
72
+ const other = newer ? prev.e : e;
73
+ const notes = [].concat(prev.e.pm_notes || [], e.pm_notes || []);
74
+ const ids = new Set();
75
+ const pm = notes.filter((x) => { const id = x && (x.id || x.at + '|' + x.text); if (ids.has(id)) return false; ids.add(id); return true; })
76
+ .sort((a, b) => String(a && a.at).localeCompare(String(b && b.at)));
77
+ by.set(k, { e: { ...keep, pm_notes: pm, agent_prep_notes: keep.agent_prep_notes || other.agent_prep_notes || '', initiative: keep.initiative || other.initiative || null }, i: newer ? i : prev.i });
78
+ });
79
+ return [...by.values()].map((x) => x.e).concat(loose);
80
+ }
81
+
55
82
  function readAll(runtimeDir, { now = Date.now() } = {}) {
56
83
  if (!runtimeDir) return [];
57
84
  let imported = [];
@@ -62,8 +89,8 @@ function readAll(runtimeDir, { now = Date.now() } = {}) {
62
89
  const briefed = brief && brief.date === today && Array.isArray(brief.calendar) ? brief.calendar.map((e) => asEvent(e, 'brief')).filter(Boolean) : null;
63
90
  let remembered = (mem && Array.isArray(mem.events) ? mem.events : []).map((e) => asEvent(e, 'memory')).filter(Boolean);
64
91
  if (briefed) remembered = remembered.filter((e) => e.at.slice(0, 10) !== today).concat(briefed);
65
- const seen = new Set(imported.filter((e) => e && e.at && !Number.isNaN(Date.parse(e.at))).map((e) => minuteKey(Date.parse(e.at), e.title)));
66
- const out = imported.slice();
92
+ const out = oneEach(imported);
93
+ const seen = new Set(out.filter((e) => e && e.at && !Number.isNaN(Date.parse(e.at))).map((e) => minuteKey(Date.parse(e.at), e.title)));
67
94
  for (const e of remembered) {
68
95
  const k = minuteKey(Date.parse(e.at), e.title);
69
96
  if (seen.has(k)) continue;
@@ -92,4 +119,4 @@ const eventsOn = (events, day) => listOf(events).filter((e) => e.date === day);
92
119
  /** Today's meetings (the Record menu): eventsOn the local day of `now`. */
93
120
  const todayOf = (events, now = Date.now()) => eventsOn(events, localDate(now));
94
121
 
95
- module.exports = { readAll, asEvent, localIso, todayOf, eventsOn, listOf, menuShape };
122
+ module.exports = { readAll, oneEach, asEvent, localIso, todayOf, eventsOn, listOf, menuShape };
@@ -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.233.0",
3
+ "version": "0.235.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.233.0",
9
+ "version": "0.235.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.233.0",
3
+ "version": "0.235.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -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
  }
@@ -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
- try { child = spawn(base.agent, args, { cwd: workdir, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true }); }
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(cmd, launchArgs, { name: 'xterm-256color', cols: 80, rows: 24, cwd: workdir, env: penv });
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
  }
@@ -24,6 +24,7 @@ Procedure:
24
24
  1. List the PM's events from the start of today to the end of the 14th day after today, from the primary calendar. Fetch every page. Skip an event the PM declined.
25
25
  2. Write the events as JSON in the scratch folder, for example `<scratch>/calendar.json`, as a list with one object per event:
26
26
  `{"id", "title", "start", "end", "attendees": ["Display Name", …], "initiative": "<initiative id or null>"}`
27
+ `id` is the calendar's own id for that event (for a repeating meeting, the id of that one occurrence). Use the same id on every run, never a made-up one: PMOS keeps one copy per id.
27
28
  `start` and `end` are ISO times with the offset, for example `2026-10-12T09:00:00+07:00`. An all-day event has the date only.
28
29
  3. Run `pmos calendar import --from <scratch>/calendar.json --sensitivity internal`. If it refuses the file, fix what it names and run it once more.
29
30
  4. Delete the scratch file.
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "one-line-setup: on Windows the background server hides the console window of every command it runs (lib/win-hide.js); `pmos` waits for its own busy server instead of starting a second one (busy-server.test.js).",
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
  }