create-pmos 0.55.0 → 0.55.2

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.
@@ -292,9 +292,11 @@ function workspace(targetArg, opts) {
292
292
  console.log(forceReinstall
293
293
  ? 'Reinstalling the Terminal Workspace app (--reinstall)…'
294
294
  : `Upgrading the Terminal Workspace app (installed ${installed || 'unknown'} → bundled ${bundled})…`);
295
+ // Stop the running server FIRST. `npm install -g` deletes and rewrites the folder that server runs from; a server
296
+ // still running there afterwards keeps its old code and starts every new session in a deleted folder.
297
+ stopWorkspaceServer();
295
298
  const status = installWorkspaceGlobal();
296
299
  if (status === 0) {
297
- stopWorkspaceServer(); // restart the persistent server on the new code
298
300
  console.log(`\n✅ pmos-workspace is now ${bundled}. Launching — if the UI still looks unchanged, hard-refresh the browser (Cmd/Ctrl+Shift+R) to drop the cached stylesheet.`);
299
301
  } else {
300
302
  console.log('\n⚠ Reinstall did not complete cleanly — launching the previously installed copy.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.55.0",
3
+ "version": "0.55.2",
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@058e723
1
+ kernel-source: pmos@b7dc0a6
2
2
  assembled: 2026-10-06
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-06T06:04:22Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T07:31:23Z"}
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-06T06:04:22Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T07:31:23Z"}
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-06T06:04:22Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T07:31:23Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -19,6 +19,25 @@ PMOS's locked decision **D62** rejects, permanently:
19
19
 
20
20
  A public server that owns a PTY running your Claude subscription is severe exposure. The tailnet path sidesteps that entirely: the phone attaches to the *same local server* across your own private network. Accordingly, this feature will **only ever** bind loopback plus your machine's own tailnet address. If you ask it to bind anything else (`0.0.0.0`, a LAN or public IP), it **refuses to start**.
21
21
 
22
+ ## Quickest: `pmos phone`
23
+
24
+ With the workspace installed (`npx create-pmos@latest workspace`, or `pmos upgrade` to update it) and Tailscale signed in on the computer and on the phone, run this in your repo folder:
25
+
26
+ ```sh
27
+ pmos phone
28
+ ```
29
+
30
+ It prints a QR code. Scan it with the phone camera, and the workspace opens signed in. You do not need a PMOS checkout, `npm install` or `PMOS_REMOTE`.
31
+
32
+ - If no workspace is running, `pmos phone` starts one in tailnet mode.
33
+ - If a workspace is running without phone access, `pmos phone` restarts it in tailnet mode. Sessions keep running in tmux and re-attach.
34
+ - If phone access is already on, `pmos phone` starts nothing and shows the same QR code again, so a phone that is signed in stays signed in.
35
+ - If Tailscale is not running, it says so. Open the Tailscale app, sign in, and run `pmos phone` again.
36
+
37
+ The QR code is the link from the server's own startup banner, which the server writes to `server-<port>.log` in the state folder. `pmos phone` reads that banner back. It does not make or store a new token. Every restart makes a new token, so after a restart or an upgrade, run `pmos phone` again and scan the new code. A workspace that had phone access keeps it when an upgrade restarts it. With `PMOS_TOKEN` set, no token is printed, so `pmos phone` shows the tailnet address and you paste your token into the sign-in page.
38
+
39
+ The steps below do the same thing by hand, from a PMOS checkout.
40
+
22
41
  ## Setup
23
42
 
24
43
  1. **Install Tailscale** on the machine running the workspace and on your phone:
@@ -7,6 +7,7 @@
7
7
  * pmos ls list the registered workspaces
8
8
  * pmos stop stop the server (running sessions end — it says so first)
9
9
  * pmos doctor diagnose the environment (node-pty arch, PTY spawn, …)
10
+ * pmos phone turn on phone access (tailnet mode) and print the phone link as a QR code
10
11
  *
11
12
  * The directory you type `pmos` in IS the workspace: it is resolved to its git root and
12
13
  * registered with the server, and the browser opens focused on it. One server hosts many
@@ -187,18 +188,18 @@ function ensureInstalled() {
187
188
  // Register the target directory and open the browser ON IT. The `focus` broadcast matters:
188
189
  // the OS may refocus an already-open tab instead of opening our URL, and without it the PM
189
190
  // would be looking at the previously-opened repo — the defect this replaces.
190
- async function openWorkspace(dir, port) {
191
+ async function openWorkspace(dir, port, { open = true } = {}) {
191
192
  const root = repoRootOf(dir);
192
193
  const reg = await api(port, 'POST', '/api/workspaces', { root, name: path.basename(root) });
193
194
  if (!reg || !reg.ok) {
194
195
  say(`✗ could not open ${root}${reg && reg.error ? ` — ${reg.error}` : ''}`);
195
- openBrowser(urlOf(port));
196
+ if (open) openBrowser(urlOf(port));
196
197
  return 1;
197
198
  }
198
199
  const w = reg.workspace;
199
200
  say(`✓ workspace “${w.name}” — ${urlOf(port)}?ws=${w.id}`);
200
201
  say(` ${root}`);
201
- openBrowser(`${urlOf(port)}?ws=${w.id}`);
202
+ if (open) openBrowser(`${urlOf(port)}?ws=${w.id}`);
202
203
  return 0;
203
204
  }
204
205
 
@@ -222,7 +223,30 @@ async function choosePort() {
222
223
  return port ? { port, movedFrom: want, owner: portOwner(want) } : { port: null, blocked: want, owner: portOwner(want) };
223
224
  }
224
225
 
225
- async function up(dir) {
226
+ // Would a tailnet start succeed here? The server's own bind rule (lib/remote-access.js resolveBinds), asked BEFORE any
227
+ // running server is stopped, so a missing Tailscale never leaves the PM with no workspace at all.
228
+ function tailnetRefusal() {
229
+ return require('../lib/remote-access').resolveBinds({ env: { ...process.env, PMOS_REMOTE: 'tailnet' } }).refused || null;
230
+ }
231
+ function tcpOpen(host, port) {
232
+ return new Promise((res) => {
233
+ const s = net.connect({ host, port, timeout: 1500 }, () => { s.destroy(); res(true); });
234
+ s.on('error', () => res(false));
235
+ s.on('timeout', () => { s.destroy(); res(false); });
236
+ });
237
+ }
238
+ // Is the server on this port serving the tailnet NOW? Its log's last banner says what mode it started in, and a
239
+ // connection to the tailnet address it named confirms the banner belongs to the live server, not to an older start.
240
+ async function servesTailnet(port) {
241
+ const P = require('../lib/phone-cli');
242
+ const b = P.lastBanner(P.readLogTail(logFile(port)));
243
+ if (!b || !b.remote || !b.tailnetUrl) return false;
244
+ try { const u = new URL(b.tailnetUrl); return await tcpOpen(u.hostname, Number(u.port)); } catch { return false; }
245
+ }
246
+
247
+ // `remote` starts a NEW server in tailnet mode (pmos phone); a server that is already running is reused as it is.
248
+ // `open: false` registers the repo without opening the desktop browser.
249
+ async function up(dir, { remote = false, open = true } = {}) {
226
250
  // 1. Is a workspace already running — on ANY port it recorded, not just the one this shell guesses?
227
251
  let live = await discover();
228
252
  // A server that predates this install is running OLD code under NEW files (or the reverse). Reusing
@@ -231,8 +255,15 @@ async function up(dir) {
231
255
  const stale = live.filter((l) => l.version !== APP_VERSION);
232
256
  if (stale.length) {
233
257
  say(`note: the running workspace is ${stale[0].version ? 'version ' + stale[0].version : 'an older version'}; this install is ${APP_VERSION}. Restarting it on the new code — sessions keep running in tmux.`);
258
+ // A server that had phone access keeps it: without this, an upgrade restart came back loopback-only with no word.
259
+ // Only when a tailnet start can succeed; otherwise it restarts loopback-only and says so.
260
+ const hadPhone = (await Promise.all(stale.map((s) => servesTailnet(s.port)))).some(Boolean);
234
261
  for (const s of stale) await stopOne(s.port);
235
262
  live = await discover();
263
+ if (hadPhone && !remote) {
264
+ if (tailnetRefusal()) say('note: phone access is off after this restart, because Tailscale is not running. Start it, then run `pmos phone`.');
265
+ else remote = true;
266
+ }
236
267
  }
237
268
  if (live.length) {
238
269
  const port = live[0].port;
@@ -245,7 +276,7 @@ async function up(dir) {
245
276
  say(`note: ${live.length} workspace servers are running (${live.map((l) => l.port).join(', ')}) — using ${port}.`);
246
277
  say(' They share this install\'s state but not each other\'s live terminals. `pmos stop` shuts them all down.');
247
278
  }
248
- return openWorkspace(dir, port);
279
+ return openWorkspace(dir, port, { open });
249
280
  }
250
281
  if (!ensureInstalled()) return 1;
251
282
  fs.mkdirSync(STATE, { recursive: true });
@@ -283,18 +314,22 @@ async function up(dir) {
283
314
  ...process.env,
284
315
  PMOS_SELF_ROOT: process.env.PMOS_SELF_ROOT || repoRootOf(dir),
285
316
  PMOS_WS_PORT: String(port),
317
+ ...(remote ? { PMOS_REMOTE: 'tailnet' } : {}),
286
318
  };
287
319
  const child = spawn(process.execPath, [path.join(WS, 'server.js')], {
288
320
  cwd: WS, detached: true, stdio: ['ignore', log, log], env,
289
321
  });
322
+ // A server that exits at once (a refused tailnet bind, a port lost to a race) ends the wait below early.
323
+ let exited = false;
324
+ child.on('exit', () => { exited = true; });
290
325
  child.unref();
291
326
  try { fs.writeFileSync(pidFile(port), String(child.pid)); } catch {}
292
327
  process.stdout.write('starting the workspace ');
293
- for (let i = 0; i < 40; i++) { // up to ~10s
328
+ for (let i = 0; i < 40 && !exited; i++) { // up to ~10s
294
329
  if (await probe(port)) {
295
330
  say('');
296
331
  say(' sessions keep running after you close this terminal · `pmos stop` shuts it down');
297
- return openWorkspace(dir, port);
332
+ return openWorkspace(dir, port, { open });
298
333
  }
299
334
  process.stdout.write('.');
300
335
  await sleep(250);
@@ -302,7 +337,7 @@ async function up(dir) {
302
337
  // Last chance before reporting failure: another launcher may have won the race and be serving
303
338
  // this repo already, on a port it recorded while we were waiting.
304
339
  live = await discover();
305
- if (live.length) { say(''); return openWorkspace(dir, live[0].port); }
340
+ if (live.length) { say(''); return openWorkspace(dir, live[0].port, { open }); }
306
341
 
307
342
  say(`\n✗ the server did not come up. Its log says:`);
308
343
  try {
@@ -376,6 +411,50 @@ async function stop() {
376
411
  return code;
377
412
  }
378
413
 
414
+ /**
415
+ * `pmos phone`: phone access in one command (initiative workspace-phone-command). The phone link is the one the
416
+ * server printed in its startup banner; lib/phone-cli.js reads it back from the server's log. So:
417
+ * - a current server already in tailnet mode is reused, and the phone keeps its sign-in;
418
+ * - otherwise every running server is stopped (sessions keep running in tmux) and one starts in tailnet mode.
419
+ */
420
+ async function phone(dir) {
421
+ const P = require('../lib/phone-cli');
422
+ const live = await discover();
423
+ let reuse = null;
424
+ for (const l of live) if (l.version === APP_VERSION && await servesTailnet(l.port)) { reuse = l; break; }
425
+ let banner;
426
+ if (reuse) {
427
+ if (await openWorkspace(dir, reuse.port, { open: false })) return 1;
428
+ banner = P.lastBanner(P.readLogTail(logFile(reuse.port)));
429
+ } else {
430
+ const refused = tailnetRefusal();
431
+ if (refused) {
432
+ say('✗ ' + refused);
433
+ say(P.startFailureHint(refused) || ' Fix the above, then run `pmos phone` again.');
434
+ if (live.length) say(' The running workspace was left as it is.');
435
+ return 1;
436
+ }
437
+ if (live.length) {
438
+ say('turning on phone access: restarting the workspace in tailnet mode. Sessions keep running in tmux and re-attach.');
439
+ for (const s of live) await stopOne(s.port);
440
+ }
441
+ // Only THIS start's banner counts: the log is append-only, and an older banner may be from another mode.
442
+ const sizes = new Map(D.candidatePorts({ stateDir: STATE, pinned: PINNED }).map((p) => [p, (() => { try { return fs.statSync(logFile(p)).size; } catch { return 0; } })()]));
443
+ const fresh = (p) => { try { return P.readLogTail(logFile(p), Math.min(P.TAIL_BYTES, Math.max(0, fs.statSync(logFile(p)).size - (sizes.get(p) || 0)))); } catch { return ''; } };
444
+ const code = await up(dir, { remote: true, open: false });
445
+ const now = await discover();
446
+ if (code || !now.length) {
447
+ const hint = P.startFailureHint([...sizes.keys()].map(fresh).join('\n'));
448
+ if (hint) say(hint);
449
+ return 1;
450
+ }
451
+ banner = P.lastBanner(fresh(now[0].port));
452
+ }
453
+ const out = P.phoneLines(banner, { ansi: !!process.stdout.isTTY && !process.env.NO_COLOR });
454
+ for (const l of out.lines) say(l);
455
+ return out.ok ? 0 : 1;
456
+ }
457
+
379
458
  function doctor() {
380
459
  const r = spawnSync(process.execPath, [path.join(WS, 'doctor.js')], { stdio: 'inherit' });
381
460
  // A child that never ran (or died to a signal) has status null — `null || 0` reported success.
@@ -391,6 +470,7 @@ function doctor() {
391
470
  else if (cmd === 'ls' || cmd === 'list') code = await ls();
392
471
  else if (cmd === 'stop') code = await stop();
393
472
  else if (cmd === 'doctor') code = doctor();
473
+ else if (cmd === 'phone') code = await phone(process.argv[3] && !process.argv[3].startsWith('-') ? path.resolve(process.argv[3]) : process.cwd());
394
474
  else if (cmd === 'schedule' || cmd === 'skill') {
395
475
  // Thin client of the running server's own routes (lib/schedule-cli.js): one store, one validator.
396
476
  const live = await discover();
@@ -431,9 +511,10 @@ function doctor() {
431
511
  if (r.error) { say('✗ could not run ' + npx + ' — ' + r.error.message); code = 1; }
432
512
  else code = r.status === null ? 1 : r.status;
433
513
  } else if (cmd === 'help' || cmd === '--help' || cmd === '-h') {
434
- say('usage: pmos [<path>|status|ls|stop|doctor|upgrade|schedule|skill|proactive|kg|item|' + require('../lib/pmos-verbs.js').names().join('|') + ']');
514
+ say('usage: pmos [<path>|status|ls|stop|doctor|phone|upgrade|schedule|skill|proactive|kg|item|' + require('../lib/pmos-verbs.js').names().join('|') + ']');
435
515
  say(' pmos open the current repo as a workspace (starts the server if needed)');
436
516
  say(' pmos <path> open another repo as a workspace');
517
+ say(' pmos phone turn on phone access over Tailscale and show the QR code to scan');
437
518
  say(' pmos schedule … create, edit and run scheduled tasks (pmos schedule --help)');
438
519
  say(' pmos skill … list, read, create and edit skills');
439
520
  say(' pmos proactive … what is waiting for you, feedback, settings (pmos proactive --help)');
@@ -23,7 +23,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
23
23
  |---|---|
24
24
  | What is waiting for you now | Today |
25
25
  | The initiatives of a repository, or of every repository | Initiatives. Choose **All repositories** in Repository ▾ for every repository. |
26
- | The branches of a repository and the sessions running in each | Repository ▾ → **Branches and sessions**. |
26
+ | The sessions of a repository, by initiative | The agent dock. ⌘K → **Branches and sessions** lists the branches. |
27
27
  | The items of an initiative | Board, or Breakdown in the initiative detail |
28
28
  | Run reports | Reports |
29
29
  | Repositories, Profiles, New sessions (the default CLI and model), Validate consent, Theme | Settings |
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Guide: layout"
4
4
  description: "The frame: one top bar, the place on screen, the detail and the agent dock; the phone tabs; the keys; and the older layout."
5
- timestamp: 2026-10-05
5
+ timestamp: 2026-10-06
6
6
  ---
7
7
 
8
8
  # Guide: layout
@@ -14,7 +14,7 @@ On a desktop screen the workspace is one frame. It has one top bar, the place on
14
14
  The top bar has these parts, from left to right:
15
15
 
16
16
  - **Repository ▾** names the current repository. Press it to choose another repository, or **All repositories** to see the work of every repository at once. All repositories replaces the old Portfolio mode.
17
- - **Branches and sessions**, at the end of Repository ▾ (or in ⌘K), opens a sheet with every branch of the repository (main first) and how many sessions run in each. Press a branch to see its sessions, open one, end one, or start a session there. **+ branch** makes a branch, and **+ session** starts one with a branch, an agent and a profile. With All repositories, the sheet lists every repository.
17
+ - **Branches and sessions** is in ⌘K. It opens a sheet with every branch of the repository (main first) and how many sessions run in each. Press a branch to see its sessions, open one, end one, or start a session there. **+ branch** makes a branch, and **+ session** starts one with a branch, an agent and a profile. With All repositories, the sheet lists every repository.
18
18
  - **Today, Initiatives, Board and Reports** are the four places. Press one to show it. The Today count shows how many items need you.
19
19
  - **Search or capture ⌘K** opens the command palette. Type to find an initiative, a repository, an open tab or a command.
20
20
  - **Sync** shows only when the current repository is not in sync with its remote. Press it to fetch, bring in the work of the other machine, record your changes and push. If the repository has not fetched in the last 10 minutes, Sync also shows, because a push from another machine is not visible until a fetch. When the repository fetched recently and is in sync, Sync is not shown. If Sync stops, the message names the step that stopped.
@@ -41,10 +41,14 @@ The dock holds your terminals, documents, conversations, skills and web pages. I
41
41
 
42
42
  - Press ⌘J (Ctrl-J), or the button on the rail, to open the dock. Press ⌘J again, or the fold button in the dock, to fold it to the rail.
43
43
  - The dock never opens by itself. A tab that opens while the dock is a rail waits in the dock until you open it.
44
- - **+ Session** in the dock starts a new session. Its menu also has **Session with options** and one item for each skill of the repository. A skill opens in its own tab in the dock.
45
- - The Web button in the dock opens a web page in a tab.
44
+ - The dock lists the sessions of the repository by initiative. A session is in the group of the initiative it is recorded on, or of its `initiative/<id>` branch. Then come **Not linked** (no initiative) and **Scheduled** (started by a schedule). Then **Open** lists the tabs that are not sessions, for example documents and skills.
45
+ - Each session row shows the name, the type of work, and the branch when it is not main and not the initiative's own branch. Press the row to bring the session to the front.
46
+ - Press **⋯** on a row to change the name, the initiative or the type of work.
47
+ - Press **✕** on a row to end the session. The row asks "End “name”?". Press **End** or **Cancel**. When you end the last running session on its own working copy, the message offers **Remove working copy**. This removes the folder only. The branch stays. If the folder has unsaved work, the workspace keeps it and tells you why.
48
+ - **+ Session** at the top of the dock, or **+** on an initiative, opens one sheet. Type a name, or leave it empty to name the session after the skill and the initiative. **For** is the initiative. **On** is where the session runs. **Automatic** uses the initiative's own working copy on `initiative/<id>` (made the first time), or the main tree when there is no initiative. You can also choose a branch that has a working copy, or **New branch…**. Then press **Blank session** (the CLI and model from Settings) or a skill. The session starts at once.
49
+ - The icons at the bottom of the dock show the sessions as a strip on top, give the dock the whole window (Focus), and fold the dock. On top, a select chooses the group, and each session of the group is a button. **+** starts a session, and **✕** ends the session in front.
46
50
  - To make the dock wider or narrower, drag its left edge. With the edge focused, the left and right arrow keys change the width in steps. Double-click the edge to go back to the default width. The width is kept in this browser.
47
- - **Focus** in the dock gives the dock the whole window. The place and the detail are hidden, and the dock stays full width on every place. Press **Exit focus**, or ⌘⇧J (Ctrl-Shift-J), to show the place again. Folding the dock also ends Focus.
51
+ - **Focus** in the dock gives the dock the whole window. The place and the detail are hidden, and the dock stays full width on every place. Press Focus again, or ⌘⇧J (Ctrl-Shift-J), to show the place again. Folding the dock also ends Focus.
48
52
  - **▲ scratchpad** in the bar under the tabs opens the scratchpad. The pad keeps one note for each terminal session, and one for the repository when no terminal is in front. **Send to terminal** types the note into the session in front.
49
53
 
50
54
  Folding the dock, or going to another place, does not touch your terminals.
@@ -32,6 +32,7 @@ The top of Today is the morning brief: a greeting, the day's calendar, the prior
32
32
  - Press **schedule the morning brief** in Today to make a daily schedule at 06:45. The schedule is an ordinary scheduled task, so it runs only while the workspace server runs. A sleeping computer gets no brief. A schedule that is made after its time of day for the first time runs within a minute, so pressing the button at 10:00 starts one brief session straight away.
33
33
  - Each source is a setting: calendar, wispr, mail, slack, jira, confluence, github, drive, figma and website. Calendar, wispr, slack, jira and confluence are on by default. Mail, github, drive, figma and website are off by default. The skill reads only subjects and senders of mail, never bodies. A source that is off cannot be reported as read.
34
34
  - **Scopes.** For Slack, Jira and Confluence you give each channel, project or space one scope. At most 50 names are kept for each service, and a name has at most 120 characters. **watch** means the session reads every message since the last read and writes a digest: the decisions, the open questions, and what touches a goal. **search** means the session reads nothing there and looks only for mentions of you and the words of your goals. **ignore** means the session does not read, search or report the place.
35
+ - **Places.** The morning brief lists every Slack channel you are a member of, every Jira project you work in and every Confluence space you are in. It reads only their names and links, not their messages. Sources shows these places, so you can give each one a scope. Before the first brief that reads a service, Sources says that its places are listed after that read. At most 200 places are kept in a brief.
35
36
  - **Websites.** Under the website source you list at most 10 addresses. The session reads each one for changes that matter to a goal or a meeting.
36
37
  - **Mentions.** In every place that is not ignored, the skill finds the mentions of you: where, who, the link to the conversation, and a short paraphrase. It never copies a message.
37
38
  - **Meetings.** Each calendar event can carry a purpose and a prep note (at most 240 characters each), up to 6 steps before, during and after the meeting (each step says whether you or an agent does it), a suggested initiative with the reason, and up to 5 links.
@@ -16,6 +16,8 @@ Above the cards, Today shows the day in two columns.
16
16
 
17
17
  Press **Prepare my day** to start the day. It runs from the brief to the delivery of each agent job, and you do not press anything else until a draft is ready for you. The page [Guide: Today and the proactive engine](proactive-engine.md) describes each step.
18
18
 
19
+ If the morning-brief session stops before it submits a brief, the Read step says so and gives the reason. **Prepare my day** is then ready again, so press it to try once more. If the session says that its folder is not there, open the repository again from its current folder first.
20
+
19
21
  Each job has one of three kinds.
20
22
 
21
23
  | Kind | Who does it | What you do |
@@ -45,6 +45,7 @@ const WORTH_ASKING = 0.5;
45
45
  const MAX_ITEMS = 30;
46
46
  const MAX_EVENTS = 20;
47
47
  const MAX_DIGESTS = 30;
48
+ const MAX_PLACES = 200; // the Slack channels, Jira projects and Confluence spaces the PM is in: names only, so Sources can list them
48
49
  const MAX_RESOLVED = 200;
49
50
  const INI_RE = /^[a-z0-9][a-z0-9._-]*$/i;
50
51
  const GOAL_RE = /^g-[0-9a-f]{8}$/;
@@ -289,6 +290,24 @@ function validateBrief(raw, ctx = {}) {
289
290
  digests.push({ source: d.source, name, link: d.link || null, decisions: lines(d.decisions, 140), open: lines(d.open, 140), goals: lines(d.goals, 140) });
290
291
  }
291
292
 
293
+ // `places`: every channel, project and space the PM is in, by name, so the PM can set a level for each (G20). Names
294
+ // and links only: nothing of what is said there.
295
+ const pl = raw.places === undefined || raw.places === null ? [] : raw.places;
296
+ if (!Array.isArray(pl)) return { ok: false, error: 'places must be a list' };
297
+ if (pl.length > MAX_PLACES) return { ok: false, error: `places has ${pl.length} entries — at most ${MAX_PLACES}` };
298
+ const places = [];
299
+ for (const [i, x] of pl.entries()) {
300
+ const at = `place ${i + 1}`;
301
+ if (!x || typeof x !== 'object' || Array.isArray(x)) return { ok: false, error: `${at} is not an object` };
302
+ if (!DIGEST_SOURCES.includes(x.source)) return { ok: false, error: `${at}: source must be one of ${DIGEST_SOURCES.join(', ')}` };
303
+ if (sources[x.source] !== 'read') return { ok: false, error: `${at}: source "${x.source}" is not reported as read, so it cannot list places` };
304
+ if (typeof x.name !== 'string' || !x.name.trim()) return { ok: false, error: `${at} needs a name (the channel, project or space)` };
305
+ const err = optText(x.name, 120, `${at}: name`) || optLink(x.link, `${at}: link`);
306
+ if (err) return { ok: false, error: err };
307
+ const name = clip(x.name, 120);
308
+ if (!places.some((y) => y.source === x.source && y.name === name)) places.push({ source: x.source, name, link: x.link || null });
309
+ }
310
+
292
311
  // `resolved` is read by a merge only: the ids of stored items a check found done or answered.
293
312
  const rs = raw.resolved === undefined || raw.resolved === null ? [] : raw.resolved;
294
313
  if (!Array.isArray(rs) || rs.length > MAX_RESOLVED || rs.some((x) => typeof x !== 'string' || !ITEM_ID_RE.test(x))) return { ok: false, error: `resolved must be a list of at most ${MAX_RESOLVED} item ids` };
@@ -300,7 +319,7 @@ function validateBrief(raw, ctx = {}) {
300
319
  judgment = { used: raw.judgment.used };
301
320
  }
302
321
  return { ok: true, brief: { date, generated_at: new Date(now).toISOString(), sources, calendar, priorities: items.filter((x) => !x.worth_asking), worth_asking: items.filter((x) => x.worth_asking), judgment,
303
- digests, resolved: [...new Set(rs)] } };
322
+ digests, places, resolved: [...new Set(rs)] } };
304
323
  }
305
324
 
306
325
  /**
@@ -310,12 +329,13 @@ function validateBrief(raw, ctx = {}) {
310
329
  * An incoming item with a stored id replaces it and keeps its first_seen and new. More than 30 is refused.
311
330
  * events by id: the fields given overwrite, notes merge by kind (wispr, mail), new events are added.
312
331
  * digests replaced by (source, name). sources 'read' wins, otherwise the stored state is kept.
332
+ * places a service's list is replaced when the incoming brief lists that service.
313
333
  * @returns {{ok:true, brief:object, added:number, removed:number}|{ok:false, error:string}}
314
334
  */
315
335
  function mergeBrief(stored, incoming, { now = Date.now() } = {}) {
316
336
  if (!incoming || typeof incoming !== 'object') return { ok: false, error: 'nothing to merge' };
317
337
  const inItems = [...(incoming.priorities || []), ...(incoming.worth_asking || [])];
318
- if (!stored || typeof stored !== 'object') return { ok: true, brief: { ...incoming, digests: incoming.digests || [], resolved: [] }, added: inItems.length, removed: 0 };
338
+ if (!stored || typeof stored !== 'object') return { ok: true, brief: { ...incoming, digests: incoming.digests || [], places: incoming.places || [], resolved: [] }, added: inItems.length, removed: 0 };
319
339
  if (stored.date !== incoming.date) return { ok: false, error: `a merge is for the stored brief of the same day — the stored brief is for ${stored.date}` };
320
340
  const at = new Date(now).toISOString();
321
341
  const gone = new Set(incoming.resolved || []);
@@ -348,6 +368,10 @@ function mergeBrief(stored, incoming, { now = Date.now() } = {}) {
348
368
  for (const d of incoming.digests || []) dg.set(d.source + '|' + d.name, d);
349
369
  if (dg.size > MAX_DIGESTS) return { ok: false, error: `the brief would hold ${dg.size} digests — at most ${MAX_DIGESTS}` };
350
370
 
371
+ // places: a merge that lists places for a service replaces that service's list; a service it does not list keeps its own.
372
+ const listed = new Set((incoming.places || []).map((x) => x.source));
373
+ const places = [...(stored.places || []).filter((x) => !listed.has(x.source)), ...(incoming.places || [])];
374
+
351
375
  const sources = {};
352
376
  for (const k of SOURCES) {
353
377
  const a = (stored.sources || {})[k]; const b = (incoming.sources || {})[k];
@@ -359,9 +383,9 @@ function mergeBrief(stored, incoming, { now = Date.now() } = {}) {
359
383
  date: stored.date, generated_at: stored.generated_at || at, updated_at: at, sources, calendar,
360
384
  priorities: items.filter((x) => !x.worth_asking), worth_asking: items.filter((x) => x.worth_asking),
361
385
  judgment: { used: !!((stored.judgment && stored.judgment.used) || (incoming.judgment && incoming.judgment.used)) },
362
- digests: [...dg.values()], resolved: [...new Set([...(stored.resolved || []), ...taken])].slice(-MAX_RESOLVED),
386
+ digests: [...dg.values()], places, resolved: [...new Set([...(stored.resolved || []), ...taken])].slice(-MAX_RESOLVED),
363
387
  } };
364
388
  }
365
389
 
366
- module.exports = { SOURCES, ITEM_SOURCES, SOURCE_STATES, OWNERS, EFFORTS, KINDS, DIGEST_SOURCES, WORTH_ASKING, MAX_ITEMS, MAX_EVENTS, MAX_DIGESTS,
390
+ module.exports = { SOURCES, ITEM_SOURCES, SOURCE_STATES, OWNERS, EFFORTS, KINDS, DIGEST_SOURCES, WORTH_ASKING, MAX_ITEMS, MAX_EVENTS, MAX_DIGESTS, MAX_PLACES,
367
391
  isUrl, validateBrief, mergeBrief, greeting, localDate };
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+ /* phone-cli.js — the pure half of `pmos phone` (initiative workspace-phone-command).
3
+ *
4
+ * pmos phone [<path>] turn on phone access for this repo and print the phone link as a QR code
5
+ *
6
+ * The launcher starts the server DETACHED, so the server's startup banner (lib/remote-access.js startupLines) goes to
7
+ * server-<port>.log and never reaches the PM's terminal. That banner is where the phone link lives: the server
8
+ * generates the token and prints it there once. `pmos phone` reads the LAST banner in that log back and prints the
9
+ * link in the terminal, so nothing new is generated, stored or served, and a phone that is signed in stays signed in.
10
+ *
11
+ * Who can read it: the person at this machine, who already has token-free loopback access to every route (D62), and
12
+ * who can already open the log. A PMOS_TOKEN token is never in the banner, so it is never printed here either.
13
+ *
14
+ * Every function here is pure or reads one file; bin/pmos.js owns starting and stopping the server.
15
+ */
16
+ const fs = require('fs');
17
+ const qr = require('./qr');
18
+
19
+ const BANNER = 'pmos workspace server — ';
20
+ const TAIL_BYTES = 256 * 1024; // the banner is near the end: it is written once per start, and the log is append-only
21
+
22
+ /** The last `TAIL_BYTES` of a log file, or '' when it does not exist. */
23
+ function readLogTail(file, bytes = TAIL_BYTES) {
24
+ try {
25
+ const size = fs.statSync(file).size;
26
+ const len = Math.min(size, bytes);
27
+ const buf = Buffer.alloc(len);
28
+ const fd = fs.openSync(file, 'r');
29
+ try { fs.readSync(fd, buf, 0, len, size - len); } finally { fs.closeSync(fd); }
30
+ return buf.toString('utf8');
31
+ } catch { return ''; }
32
+ }
33
+
34
+ /**
35
+ * What the most recent startup banner in a log says. null when the log holds no banner.
36
+ * remote the server bound the tailnet (REMOTE mode)
37
+ * tailnetUrl http://100.x.y.z:<port> — present in REMOTE mode
38
+ * phoneUrl the signed-in link — present only for a GENERATED token
39
+ * ownToken the server uses PMOS_TOKEN, so no link was printed
40
+ */
41
+ function lastBanner(text) {
42
+ const lines = String(text || '').split(/\r?\n/);
43
+ let at = -1;
44
+ for (let i = lines.length - 1; i >= 0; i--) if (lines[i].startsWith(BANNER)) { at = i; break; }
45
+ if (at < 0) return null;
46
+ const head = lines[at];
47
+ const out = { remote: /REMOTE \(tailnet\) mode/.test(head), tailnetUrl: null, phoneUrl: null, ownToken: false };
48
+ if (!out.remote) return out;
49
+ // The REMOTE banner is a few lines plus a QR code (about 30 lines at version 4). Read no further than its end, so
50
+ // a later log line that happens to look like one of these cannot be mistaken for it.
51
+ for (const l of lines.slice(at + 1, at + 80)) {
52
+ if (/^\S/.test(l)) break; // the banner's lines are all indented
53
+ let m;
54
+ if (!out.tailnetUrl && (m = /^\s+tailnet:\s+(http:\/\/\S+)/.exec(l))) out.tailnetUrl = m[1];
55
+ if (!out.phoneUrl && (m = /^\s+or open:\s+(http:\/\/\S+\?token=\S+)/.exec(l))) out.phoneUrl = m[1];
56
+ if (/paste your PMOS_TOKEN/.test(l)) out.ownToken = true;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /** The lines `pmos phone` prints for a banner, and whether that is a success. */
62
+ function phoneLines(banner, { ansi = false } = {}) {
63
+ if (!banner || !banner.remote) {
64
+ return { ok: false, lines: [
65
+ '✗ phone access is not on: the workspace server did not start in tailnet mode.',
66
+ ' Run `pmos stop`, then `pmos phone` again. `pmos doctor` diagnoses the rest.',
67
+ ] };
68
+ }
69
+ if (banner.phoneUrl) {
70
+ const lines = ['', ' Scan this with your phone camera. The phone must be signed in to the same Tailscale tailnet.', ''];
71
+ try {
72
+ for (const l of qr.toTerminal(qr.encode(banner.phoneUrl), { ansi })) lines.push(' ' + l);
73
+ } catch { lines.push(' (this link is too long for a QR code; type it instead)'); }
74
+ lines.push('', ` or open: ${banner.phoneUrl}`, '');
75
+ lines.push(' The link signs the phone in. It changes when the workspace restarts; run `pmos phone` again then.');
76
+ return { ok: true, lines };
77
+ }
78
+ if (banner.ownToken && banner.tailnetUrl) {
79
+ return { ok: true, lines: [
80
+ ` phone: open ${banner.tailnetUrl}/ on your phone and paste your PMOS_TOKEN into the sign-in field.`,
81
+ ' (A token you set yourself is never printed, so there is no QR code.)',
82
+ ] };
83
+ }
84
+ return { ok: false, lines: ['✗ phone access is on, but the server log has no phone link. Run `pmos stop`, then `pmos phone`.'] };
85
+ }
86
+
87
+ /** The extra line to print when starting in tailnet mode failed because no tailnet address was found. */
88
+ function startFailureHint(logText) {
89
+ return /no Tailscale tailnet address|Is Tailscale up\?/.test(String(logText || ''))
90
+ ? ' Tailscale is not running on this computer. Open the Tailscale app and sign in (or run `tailscale up`), then run `pmos phone` again.'
91
+ : null;
92
+ }
93
+
94
+ module.exports = { readLogTail, lastBanner, phoneLines, startFailureHint, BANNER, TAIL_BYTES };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.55.0",
3
+ "version": "0.55.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.55.0",
3
+ "version": "0.55.2",
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",
@@ -483,7 +483,7 @@ function repoMenu(anchor) {
483
483
  const items = tuples.map((t) => ({ label: t[0], icon: t[0] === cur ? 'check' : null, onSelect: () => chooseRepo(t[0]) }));
484
484
  if (items.length) items.push({ sep: true });
485
485
  items.push({ label: FC.repo.all, icon: fr.scope === 'all' ? 'check' : null, onSelect: chooseAll });
486
- if (tuples.length) items.push({ sep: true }, { label: FC.repo.branches, onSelect: openBranchesSheet });
486
+ // 0.55.1: Branches and sessions left this menu; the dock lists the sessions, and ⌘K still opens the sheet
487
487
  menu({ anchor, items, ariaLabel: FC.repo.menu });
488
488
  }
489
489