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.
- package/bin/create-pmos.js +3 -1
- 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/REMOTE.md +19 -0
- package/workspace/bin/pmos.js +90 -9
- package/workspace/guide/index.md +1 -1
- package/workspace/guide/layout.md +9 -5
- package/workspace/guide/proactive-engine.md +1 -0
- package/workspace/guide/today.md +2 -0
- package/workspace/lib/brief.js +28 -4
- package/workspace/lib/phone-cli.js +94 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/app.js +1 -1
- package/workspace/public/pm/components.css +139 -0
- package/workspace/public/pm/data.js +8 -0
- package/workspace/public/pm/dock-sessions.js +373 -0
- package/workspace/public/pm/dock.js +21 -37
- package/workspace/public/pm/frame-copy.js +50 -0
- package/workspace/public/pm/icons.js +1 -1
- package/workspace/public/pm/panel-tabs.js +46 -11
- package/workspace/public/pm/tab-term.js +13 -1
- package/workspace/public/pm/today-copy.js +3 -0
- package/workspace/public/pm/today-day.js +11 -5
- package/workspace/public/vendor/lucide-sprite.js +8 -2
- package/workspace/server.js +86 -10
- package/workspace/skills/morning-brief.skill +3 -1
- package/workspace/test-lists/workspace-phone-command.json +5 -0
- package/workspace/third-party/README.md +1 -1
package/bin/create-pmos.js
CHANGED
|
@@ -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.
|
|
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": {
|
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-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-
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T07:31:23Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/REMOTE.md
CHANGED
|
@@ -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:
|
package/workspace/bin/pmos.js
CHANGED
|
@@ -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
|
-
|
|
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++) {
|
|
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)');
|
package/workspace/guide/index.md
CHANGED
|
@@ -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
|
|
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-
|
|
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
|
|
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
|
-
-
|
|
45
|
-
-
|
|
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
|
|
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.
|
package/workspace/guide/today.md
CHANGED
|
@@ -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 |
|
package/workspace/lib/brief.js
CHANGED
|
@@ -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 };
|
package/workspace/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|