create-pmos 0.18.0 → 0.19.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/README.md +57 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/scripts/metrics.py +9 -2
- package/workspace/README.md +21 -1
- package/workspace/lib/file-mode-parse.js +12 -1
- package/workspace/lib/start-policy.js +118 -0
- package/workspace/lib/tree-share.js +171 -13
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +2 -2
- package/workspace/public/index.html +6 -0
- package/workspace/public/pm/app.js +44 -7
- package/workspace/public/pm/data.js +117 -5
- package/workspace/public/pm/panel-initiatives.js +7 -0
- package/workspace/public/pm/panel-tabs.js +339 -10
- package/workspace/public/pm/state.js +71 -6
- package/workspace/public/pm/tab-doc.js +5 -5
- package/workspace/public/pm/tab-task.js +1 -1
- package/workspace/public/pm/tab-term.js +151 -19
- package/workspace/server.js +186 -9
package/README.md
CHANGED
|
@@ -333,6 +333,63 @@ Refreshes the skills, concepts, and scripts only. Your records — `state/`, `pl
|
|
|
333
333
|
`okf/product/` — are yours, and an update never touches them. `pmos/KIT-VERSION` always says
|
|
334
334
|
exactly what you're running.
|
|
335
335
|
|
|
336
|
+
## What's new in 0.19.0
|
|
337
|
+
|
|
338
|
+
**A long document scrolls its own panel, and starting a session asks what is already running.** Run
|
|
339
|
+
`npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
|
|
340
|
+
|
|
341
|
+
- **Opening a long document no longer scrolls the whole window.** Scrolling a document in the right
|
|
342
|
+
panel moved the entire interface — the top bar slid away, the status bar rose from below — while
|
|
343
|
+
the panel's own scrollbar never engaged. Every panel now scrolls inside itself and the app frame
|
|
344
|
+
never moves. (If you are curious: the shell's middle row was written `1fr`, which is
|
|
345
|
+
`minmax(auto, 1fr)`, so a tall document enlarged the row rather than overflowing it. Every style
|
|
346
|
+
involved read as correct, which is why it survived review.)
|
|
347
|
+
- **`+ session` now asks before it starts one, when the checkout is already busy.** It used to
|
|
348
|
+
start the session and *then* tell you how many others were in there — a count arriving after the
|
|
349
|
+
decision, naming something you cannot act on. Now you get a chooser first, listing every session
|
|
350
|
+
in that checkout.
|
|
351
|
+
- **It says what each one is.** Its name, its agent, the same word the sidebar uses (`working` ·
|
|
352
|
+
`quiet 12m` · `needs you` · `maybe blocked`), the agent's own last words, and who holds the writer
|
|
353
|
+
claim.
|
|
354
|
+
- **…and what ending it would cost, without over-claiming.** Only `needs you` reads as settled —
|
|
355
|
+
its turn finished and it is waiting on you. `working` says output is arriving right now. `quiet`
|
|
356
|
+
and `maybe blocked` say, in as many words, that the workspace *cannot tell* a finished turn from a
|
|
357
|
+
permission prompt from a stuck tool. A confident "safe to end" that discarded a running turn would
|
|
358
|
+
be worse than no reading at all.
|
|
359
|
+
- **You can take the running session instead of making another.** `open it` attaches a tab to it and
|
|
360
|
+
starts nothing. `end it` is there too — the first click arms it and names what it will end, the
|
|
361
|
+
second does it. `+ start another session here anyway` is always one click: being told is never
|
|
362
|
+
being blocked, and no session is ever refused because another is there.
|
|
363
|
+
- **The same detail is written into the new session's scrollback**, so scrolling back tomorrow
|
|
364
|
+
answers the same question the chooser did.
|
|
365
|
+
|
|
366
|
+
## What's new in 0.18.1
|
|
367
|
+
|
|
368
|
+
**Three fixes to the workspace terminal, all of them about what the window knew about a session.**
|
|
369
|
+
Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
|
|
370
|
+
|
|
371
|
+
- **Your terminal now uses its whole width.** Nothing ever told the shell how wide the browser
|
|
372
|
+
was. A session starts at 80 columns; the server stated its own size on attach rather than
|
|
373
|
+
adopting yours, and the browser only spoke up when a resize *changed* its grid — which happened
|
|
374
|
+
once, while the connection was still opening and there was no session to tell. So the process on
|
|
375
|
+
the other end kept wrapping at 80 columns inside a window three times that wide, and a
|
|
376
|
+
full-screen agent CLI painted into a box that was not there.
|
|
377
|
+
- **The window stops rebuilding itself while you type.** It repainted every panel every four
|
|
378
|
+
seconds whether or not anything had changed — on a project with a hundred-odd initiatives that is
|
|
379
|
+
a real stall, on a timer, next to a terminal you are typing into. It now repaints when something
|
|
380
|
+
it shows actually moves. Separately, the server rewrote every session's scrollback file to disk
|
|
381
|
+
every five seconds, synchronously, whether or not a byte had changed; it now writes only what
|
|
382
|
+
moved.
|
|
383
|
+
- **The scratchpad belongs to a session again.** It had become one shared buffer for the whole
|
|
384
|
+
page, so every terminal — in every repository — showed and overwrote the same text, and none of
|
|
385
|
+
it outlived the tab. Each session has its own pad again, saved on your machine, and it comes back
|
|
386
|
+
with the session.
|
|
387
|
+
- **Reopening the workspace brings your sessions back.** Open tabs are remembered and re-attach to
|
|
388
|
+
the exact session they were on, named on the tab so you can tell which one it is. Previously they
|
|
389
|
+
were forgotten on reload and had to be re-found by hand — and the re-finding matched on a session
|
|
390
|
+
*name*, which is not unique between repositories, so a tab in one project could silently attach
|
|
391
|
+
to a shell running in another. `+ session` also always starts a new session now, instead of
|
|
392
|
+
sometimes adopting one that was already running.
|
|
336
393
|
## What's new in 0.18.0
|
|
337
394
|
|
|
338
395
|
**Several sessions in one folder now say so.** Run `npx create-pmos@latest workspace`; the version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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. `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
|
@@ -49,11 +49,18 @@ def review_gate_pass(verdict, score):
|
|
|
49
49
|
|
|
50
50
|
def leading_frontmatter(path):
|
|
51
51
|
"""The LEADING frontmatter block only — body text cannot fake a declaration. Mirror of
|
|
52
|
-
workspace/lib/file-mode-parse.js parseLeadingFrontmatter (agreement enforced by the fixture test).
|
|
52
|
+
workspace/lib/file-mode-parse.js parseLeadingFrontmatter (agreement enforced by the fixture test).
|
|
53
|
+
|
|
54
|
+
The value group is `(.*?)`, NOT `(.+?)`. A key written bare (`prd_path:` with nothing after it)
|
|
55
|
+
is legal YAML for a null value; under `.+?` the line does not match, so it reads as the first
|
|
56
|
+
body line, the loop breaks, and every key below it is dropped. Agreement with the JS parser was
|
|
57
|
+
never sufficient to catch this — both carried the same wrong regex, so the fixture compared two
|
|
58
|
+
identical mistakes and passed. See kit/fixtures/state/initiatives/epsilon-empty-value.md.
|
|
59
|
+
"""
|
|
53
60
|
fm = {}
|
|
54
61
|
with open(path, encoding="utf-8") as fh:
|
|
55
62
|
for line in fh:
|
|
56
|
-
m = re.match(r"^([a-z_]+):\s*(
|
|
63
|
+
m = re.match(r"^([a-z_]+):\s*(.*?)\s*$", line.rstrip("\n"))
|
|
57
64
|
if m:
|
|
58
65
|
fm.setdefault(m.group(1), m.group(2))
|
|
59
66
|
elif line.strip() and not line.startswith("---"):
|
package/workspace/README.md
CHANGED
|
@@ -162,6 +162,24 @@ session in a tree that already has live ones now says so in the pane — naming
|
|
|
162
162
|
way out (⑂ a new branch in its own folder). It fires on the plain `+ session` path too, where no
|
|
163
163
|
git operation runs at all: the server already knew, and used to say nothing.
|
|
164
164
|
|
|
165
|
+
**`+ session` asks before it starts one.** A count arriving *after* the spawn is a fact you cannot
|
|
166
|
+
act on, and the answer you usually want is not "start another" at all — it is "which of those is the
|
|
167
|
+
one I was already working in?". So clicking `+ session` into an occupied tree opens a chooser
|
|
168
|
+
first, listing every session in that tree with:
|
|
169
|
+
|
|
170
|
+
- **what it is** — its name, its agent, the sidebar's own word for its state (`working` ·
|
|
171
|
+
`quiet 12m` · `needs you` · `maybe blocked`), the agent's last words, and the writer claim;
|
|
172
|
+
- **what ending it would cost** — one sentence per session, worded from the evidence. Only
|
|
173
|
+
`needs you` reads as settled ("its turn finished and it is waiting on you"). `quiet` and
|
|
174
|
+
`maybe blocked` say, in as many words, that PMOS *cannot tell* — a confident "safe" that
|
|
175
|
+
discards a running turn is worse than no reading at all;
|
|
176
|
+
- **`open it`** — attach a tab to that session and start nothing.
|
|
177
|
+
|
|
178
|
+
`end it` is there too, and is the only destructive control on the surface: two clicks, the first
|
|
179
|
+
arming it. `+ start another session here anyway` is always one click away — informing you is never
|
|
180
|
+
obstructing you. The same per-session detail is written into the new pane's scrollback, so scrolling
|
|
181
|
+
back tomorrow answers the same question the chooser did.
|
|
182
|
+
|
|
165
183
|
**The tree is the unit, not the folder you registered.** A repo registered twice — say `~/repo` and
|
|
166
184
|
`~/repo/packages/app`, the ordinary shape for a product repo added by path — is **one** tree, and
|
|
167
185
|
its sessions are told about each other. Two *worktrees* of one repo are **two** trees, and are not
|
|
@@ -178,7 +196,9 @@ Be clear about what this buys: **PMOS hosts your agent's terminal, it does not s
|
|
|
178
196
|
the files.** Nothing here prevents a write, a checkout or a commit, and no spawn is ever refused
|
|
179
197
|
because someone else is in the tree. It makes a shared tree visible; the discipline is still yours.
|
|
180
198
|
(The role rides on `/api/sessions` rows as `tree` / `tree_role` / `tree_peers`; the tab-bar chip is
|
|
181
|
-
not wired yet.
|
|
199
|
+
not wired yet. `/api/workspaces` publishes each registration's `tree`, which is what lets the
|
|
200
|
+
chooser answer "who is already in there?" *before* anything is started; `/api/tree/peers` returns
|
|
201
|
+
each session described — `shown`, `label`, `doing` and a `risk` — rather than a list of ids.)
|
|
182
202
|
|
|
183
203
|
A workspace anywhere **inside** a repo knows its branch — a subdirectory (`repo/packages/app`)
|
|
184
204
|
counts, which matters for a product repo added by path. A folder with **no git** simply says so and
|
|
@@ -14,10 +14,21 @@ const reviewGate = require('./review-gate');
|
|
|
14
14
|
// The LEADING YAML-ish frontmatter block ONLY — body text must never be able to fake a declaration.
|
|
15
15
|
// Mirrors metrics.py exactly: `key: value` lines (first value wins), `---` fences and blank lines are
|
|
16
16
|
// skipped, and the FIRST non-frontmatter, non-fence, non-blank line ends the block.
|
|
17
|
+
//
|
|
18
|
+
// The value group is `(.*?)`, NOT `(.+?)`. A key written bare — `prd_path:` with nothing after it —
|
|
19
|
+
// is legal YAML for a null value, and 30 of the 136 records in planning/initiatives/ are written
|
|
20
|
+
// that way. Under `.+?` such a line does not match, so it is treated as the first body line, the
|
|
21
|
+
// loop BREAKS, and every key below it is silently dropped: parent_kr, outcome_metric, the lot. The
|
|
22
|
+
// spine then rendered those initiatives as "unanchored — rejected work under D59".
|
|
23
|
+
//
|
|
24
|
+
// This is not a hypothetical. metrics.py carried the identical regex and kit/fixtures/test_parsers.py
|
|
25
|
+
// existed to prove the two agreed — which they did, on being wrong, for as long as the fixture only
|
|
26
|
+
// ever compared them to each other. scripts/workflow-state.js:105 was the one correct parser and was
|
|
27
|
+
// compared against nothing. The fixture now asserts VALUES; see epsilon-empty-value.md.
|
|
17
28
|
function parseLeadingFrontmatter(text) {
|
|
18
29
|
const fm = {};
|
|
19
30
|
for (const line of String(text == null ? '' : text).split('\n')) {
|
|
20
|
-
const m = line.match(/^([a-z_]+):\s*(
|
|
31
|
+
const m = line.match(/^([a-z_]+):\s*(.*?)\s*$/);
|
|
21
32
|
if (m) { if (!(m[1] in fm)) fm[m[1]] = m[2]; continue; }
|
|
22
33
|
if (line.trim() && !line.startsWith('---')) break;
|
|
23
34
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/* workspace-repo-first · the start-work policy (PRD R1 · R3)
|
|
2
|
+
* ──────────────────────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
* ONE declared rule for where a session starts, replacing a decision the server used to make per
|
|
4
|
+
* call from invisible state.
|
|
5
|
+
*
|
|
6
|
+
* What it replaced, and why that had to go. `spawnWithBranch` chose isolation with
|
|
7
|
+
*
|
|
8
|
+
* if (isolate === undefined || isolate === null) isolate = liveSessionCount(w.id) > 0;
|
|
9
|
+
*
|
|
10
|
+
* — so the SAME click created a worktree or moved the shared checkout depending on whether some
|
|
11
|
+
* other session happened to be alive at that moment. A PM cannot hold that rule in their head,
|
|
12
|
+
* cannot predict it, and cannot see it in the UI before pressing the button. It is not a policy;
|
|
13
|
+
* it is a coin flip with a plausible-looking reason.
|
|
14
|
+
*
|
|
15
|
+
* The rule this module states instead:
|
|
16
|
+
*
|
|
17
|
+
* no initiative chosen → the repository's MAIN working tree, on whatever branch it is on.
|
|
18
|
+
* No git operation at all. Nothing moves, no folder appears.
|
|
19
|
+
* an initiative chosen → that initiative's own worktree on `initiative/<id>`, created if absent
|
|
20
|
+
* and REUSED if present. The main tree is never moved off its branch.
|
|
21
|
+
*
|
|
22
|
+
* The PM's framing, which this encodes: keep the main checkout on main, and let work live in
|
|
23
|
+
* worktrees rather than accumulating branches in the shared tree.
|
|
24
|
+
*
|
|
25
|
+
* Two consequences worth stating because they are easy to get wrong:
|
|
26
|
+
*
|
|
27
|
+
* * If the MAIN tree already happens to be on `initiative/<id>`, the answer is the main tree —
|
|
28
|
+
* not a second folder for a branch that is already checked out. git allows one working tree
|
|
29
|
+
* per branch, so building a worktree here would fail, and failing is the better outcome only
|
|
30
|
+
* if the alternative were wrong. It isn't: the PM asked to work on that initiative and the
|
|
31
|
+
* repository is already there.
|
|
32
|
+
* * `create` is derived from whether the branch EXISTS, never assumed. `addWorktree(root, b,
|
|
33
|
+
* {create:true})` errors when the branch is already there, and `{create:false}` errors when it
|
|
34
|
+
* is not. Guessing produces a failed git operation, and a failed git operation is the one thing
|
|
35
|
+
* the rubric's veto forbids being followed by a spawned session.
|
|
36
|
+
*
|
|
37
|
+
* PURE ON PURPOSE. Everything it needs arrives as plain data, so the whole policy — including the
|
|
38
|
+
* paths that touch git — is exercisable in node with no repository, no socket and no PTY. The
|
|
39
|
+
* predecessor's hard floor ("a failed git operation must never start a session in the wrong
|
|
40
|
+
* directory") is inherited by this initiative, and a floor whose only proof is a manual click is
|
|
41
|
+
* not a floor.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const BRANCH_PREFIX = 'initiative/';
|
|
45
|
+
|
|
46
|
+
// The branch an initiative's work belongs on. One definition, so the client's preview and the
|
|
47
|
+
// server's git call cannot disagree about what the PM is about to get.
|
|
48
|
+
function branchForInitiative(id) {
|
|
49
|
+
const s = String(id == null ? '' : id).trim();
|
|
50
|
+
return s ? BRANCH_PREFIX + s : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The repository's MAIN working tree among the registered ones. `repo_key` IS the main worktree's
|
|
54
|
+
// directory (repoIdentity resolves it from `git worktree list`, which names the main tree first),
|
|
55
|
+
// so the main tree is the one whose root equals the key. A repo where only LINKED worktrees are
|
|
56
|
+
// registered has no main tree on screen; the first registered tree stands in, which is the same
|
|
57
|
+
// fallback surfaceProducts already makes for the same reason.
|
|
58
|
+
function mainTreeOf(trees) {
|
|
59
|
+
const list = (trees || []).filter(Boolean);
|
|
60
|
+
if (!list.length) return null;
|
|
61
|
+
return list.find((t) => t.repo_key && t.root && String(t.root) === String(t.repo_key)) || list[0];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Resolve one "start work here" request to a plan.
|
|
65
|
+
*
|
|
66
|
+
* trees — the registered workspaces of ONE repository: { id, root, repo_key, branch }
|
|
67
|
+
* initiative — an initiative id, or null/'' for "just give me a terminal"
|
|
68
|
+
* branches — branch names known to the repo (branchInfo().branches), or null when unread
|
|
69
|
+
*
|
|
70
|
+
* Returns { ok, plan, wsId, branch, createBranch, isolate, why } — or { ok:false, error } when
|
|
71
|
+
* there is nothing to act on. `wsId` is the workspace the session is spawned against; `branch`
|
|
72
|
+
* non-null means a git operation is required first, and `isolate` says which kind.
|
|
73
|
+
*/
|
|
74
|
+
function resolveStart({ initiative, trees, branches } = {}) {
|
|
75
|
+
const list = (trees || []).filter(Boolean);
|
|
76
|
+
if (!list.length) return { ok: false, error: 'no working tree is registered for this repository' };
|
|
77
|
+
const main = mainTreeOf(list);
|
|
78
|
+
if (!main) return { ok: false, error: 'no working tree is registered for this repository' };
|
|
79
|
+
|
|
80
|
+
const id = String(initiative == null ? '' : initiative).trim();
|
|
81
|
+
if (!id) {
|
|
82
|
+
return {
|
|
83
|
+
ok: true, plan: 'main-tree', wsId: main.id, branch: null, createBranch: false, isolate: false,
|
|
84
|
+
why: `runs in ${main.name || 'the main working tree'}${main.branch ? ` on ${main.branch}` : ''} — no branch is checked out and nothing else moves`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const branch = branchForInitiative(id);
|
|
89
|
+
if (!branch) return { ok: false, error: 'an initiative id is required to derive a branch' };
|
|
90
|
+
|
|
91
|
+
// Already checked out in a LINKED worktree → go there. No git operation; the folder exists.
|
|
92
|
+
const existing = list.find((t) => t.id !== main.id && t.branch === branch);
|
|
93
|
+
if (existing) {
|
|
94
|
+
return {
|
|
95
|
+
ok: true, plan: 'reuse-worktree', wsId: existing.id, branch: null, createBranch: false, isolate: false,
|
|
96
|
+
why: `${id} already has its own folder — this reuses it, and creates nothing`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// The MAIN tree is already on it. Use it rather than attempting a worktree git would refuse.
|
|
100
|
+
if (main.branch === branch) {
|
|
101
|
+
return {
|
|
102
|
+
ok: true, plan: 'main-tree-on-branch', wsId: main.id, branch: null, createBranch: false, isolate: false,
|
|
103
|
+
why: `this repository is already on ${branch} — the session runs here, and no second folder is made`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// Otherwise: a new worktree, off the main tree, which stays exactly where it is.
|
|
107
|
+
// `create` is DERIVED. A null `branches` means the branch list was never read — assuming the
|
|
108
|
+
// branch is new would make `git worktree add -b` fail on every existing branch, so the honest
|
|
109
|
+
// default is to treat it as existing and let git's own error surface if it is not.
|
|
110
|
+
const known = Array.isArray(branches) ? branches : null;
|
|
111
|
+
const createBranch = known ? !known.includes(branch) : false;
|
|
112
|
+
return {
|
|
113
|
+
ok: true, plan: 'new-worktree', wsId: main.id, branch, createBranch, isolate: true,
|
|
114
|
+
why: `${branch} gets its own folder — ${main.name || 'the main working tree'} stays on ${main.branch || 'its branch'} and every running session stays put`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { resolveStart, branchForInitiative, mainTreeOf, BRANCH_PREFIX };
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// Who shares a working tree with whom,
|
|
2
|
+
// Who shares a working tree with whom, who is DECLARED to be driving it, and — since
|
|
3
|
+
// workspace-session-choice — what each of those sessions is actually DOING and what ending it
|
|
4
|
+
// would cost.
|
|
3
5
|
//
|
|
4
6
|
// The premise, stated once so nothing below has to hedge: PMOS hosts real PTYs and the agent CLI
|
|
5
7
|
// inside one is untouched (D62). There is nothing between an agent and the filesystem, so this
|
|
@@ -21,6 +23,23 @@
|
|
|
21
23
|
//
|
|
22
24
|
// Pure functions over plain rows, on purpose: the server owns the git shell-outs and the session
|
|
23
25
|
// registry, and the rules are the part worth testing without either.
|
|
26
|
+
//
|
|
27
|
+
// ── ONE module, TWO consumers ────────────────────────────────────────────────────────────────
|
|
28
|
+
// This file is now a UMD, for the same reason lib/session-state.js is: the SERVER computes the
|
|
29
|
+
// co-tenancy sentence at spawn, and the BROWSER computes the same descriptions again when the PM
|
|
30
|
+
// clicks `+ session` into an occupied tree. A second copy of "what does ending this cost?" would
|
|
31
|
+
// be free to drift, and the two surfaces would end up holding different opinions about one
|
|
32
|
+
// session — the exact failure the session-state table was extracted to stop.
|
|
33
|
+
//
|
|
34
|
+
// What this module deliberately does NOT do is name a session's STATE. The vocabulary (working ·
|
|
35
|
+
// quiet · needs you · maybe blocked · exited) belongs to lib/session-state.js and arrives here
|
|
36
|
+
// already worded, on the row, as `shown` + `label`. Re-deriving a word here would be that second
|
|
37
|
+
// copy in a new costume.
|
|
38
|
+
(function (root, factory) {
|
|
39
|
+
const api = factory();
|
|
40
|
+
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
|
41
|
+
else root.PMOSTreeShare = api;
|
|
42
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
24
43
|
|
|
25
44
|
/** Live = a session the PM could still be driving. `exited` is a corpse and collides with nobody. */
|
|
26
45
|
function isLive(s) { return !!s && s.state !== 'exited'; }
|
|
@@ -73,18 +92,138 @@ function nameList(rows, cap = 3) {
|
|
|
73
92
|
return `${names.slice(0, cap).join(', ')} and ${names.length - cap} more`;
|
|
74
93
|
}
|
|
75
94
|
|
|
95
|
+
/** The same list, each pane carrying the word beside it — `label` when the caller supplied one. */
|
|
96
|
+
function statedList(rows, cap = 3) {
|
|
97
|
+
const parts = (rows || []).map((s) => (s && s.label ? `“${s.name}” (${s.label})` : `“${s.name}”`));
|
|
98
|
+
if (parts.length <= cap) return parts.join(', ');
|
|
99
|
+
return `${parts.slice(0, cap).join(', ')} and ${parts.length - cap} more`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A terse age, in the voice the sidebar already uses. `null` in, `null` out — never "0s". */
|
|
103
|
+
function ago(ms) {
|
|
104
|
+
if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
|
|
105
|
+
if (ms < 60000) return `${Math.max(1, Math.round(ms / 1000))}s`;
|
|
106
|
+
if (ms < 3600000) return `${Math.round(ms / 60000)}m`;
|
|
107
|
+
return `${Math.round(ms / 360000) / 10}h`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* WHAT ENDING THIS SESSION WOULD COST — read only from what the workspace can actually observe.
|
|
112
|
+
*
|
|
113
|
+
* The PM's question, asked in as many words, is "is it safe to end?". The honest answer has three
|
|
114
|
+
* shapes and only one of them is a yes, so the levels are named for the EVIDENCE rather than for a
|
|
115
|
+
* recommendation:
|
|
116
|
+
*
|
|
117
|
+
* 'in-flight' — bytes are arriving right now. Ending it cuts a turn that is running. Not safe,
|
|
118
|
+
* and this one PMOS is sure of.
|
|
119
|
+
* 'settled' — the turn finished and the session is waiting on the human (`needs`). This is the
|
|
120
|
+
* only state where "nothing is in flight" is CORROBORATED — session-state reaches
|
|
121
|
+
* `needs` only from a transcript with no unresolved tool call, over a quiet clock.
|
|
122
|
+
* 'unknown' — quiet, or a tool call open with nothing moving. The workspace cannot tell a
|
|
123
|
+
* finished turn from a permission prompt from a hung tool, and this is the state it
|
|
124
|
+
* must not launder into a yes. Saying "probably idle" is the whole point: an
|
|
125
|
+
* over-confident "safe" here is the watermelon this repo has a word for.
|
|
126
|
+
* 'gone' — already exited; there is nothing left to end.
|
|
127
|
+
*
|
|
128
|
+
* `shown` is the presented state from lib/session-state.js. An unrecognised value falls to
|
|
129
|
+
* 'unknown', because a state this module has never heard of is precisely not a state it may
|
|
130
|
+
* declare safe.
|
|
131
|
+
*/
|
|
132
|
+
function endRisk(shown, quietMs) {
|
|
133
|
+
const age = ago(quietMs);
|
|
134
|
+
if (shown === 'exited') {
|
|
135
|
+
return { level: 'gone', why: 'already exited — there is nothing left to end' };
|
|
136
|
+
}
|
|
137
|
+
if (shown === 'working') {
|
|
138
|
+
return { level: 'in-flight', why: 'output is arriving right now — ending it cuts a turn that is still running' };
|
|
139
|
+
}
|
|
140
|
+
if (shown === 'needs') {
|
|
141
|
+
return {
|
|
142
|
+
level: 'settled',
|
|
143
|
+
why: 'its turn finished and it is waiting on you — ending it costs the scrollback, not work in flight',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (shown === 'blocked') {
|
|
147
|
+
return {
|
|
148
|
+
level: 'unknown',
|
|
149
|
+
why: `a tool call has been open${age ? ` ${age}` : ''} with nothing moving — PMOS cannot tell a permission prompt from a stuck tool, so ending it may or may not discard work`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
level: 'unknown',
|
|
154
|
+
why: `nothing has moved${age ? ` for ${age}` : ''}, and PMOS cannot see whether the turn finished — read this as “probably idle”, not “safe”`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* One co-tenant, described for a human who is deciding between joining it and starting another.
|
|
160
|
+
*
|
|
161
|
+
* Everything here is either copied off the row or derived by `endRisk`; nothing is invented. In
|
|
162
|
+
* particular `doing` is the agent's own last words as the sidebar already shows them, and it stays
|
|
163
|
+
* null when the workspace could not read them rather than becoming a cheerful placeholder.
|
|
164
|
+
*/
|
|
165
|
+
function describeTenant(row, writerId) {
|
|
166
|
+
const s = row || {};
|
|
167
|
+
const shown = s.shown || (s.state === 'exited' ? 'exited' : 'working');
|
|
168
|
+
return {
|
|
169
|
+
id: s.id || null,
|
|
170
|
+
ws: s.ws || null,
|
|
171
|
+
name: s.name || '(unnamed)',
|
|
172
|
+
agent: s.agent || null,
|
|
173
|
+
shown,
|
|
174
|
+
label: s.label || shown,
|
|
175
|
+
doing: s.doing || null,
|
|
176
|
+
branch: s.branch || null,
|
|
177
|
+
writer: !!(writerId && s.id === writerId),
|
|
178
|
+
risk: endRisk(shown, s.quietMs),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The whole answer to "you are about to start a session in a tree that is already busy".
|
|
184
|
+
*
|
|
185
|
+
* Returned as DATA rather than as a paragraph, because the two consumers need different shapes of
|
|
186
|
+
* it: the server writes a sentence into the pane's scrollback, and the browser draws a chooser with
|
|
187
|
+
* a button per peer. Both read the same `peers`, so a peer the chooser offers and a peer the pane
|
|
188
|
+
* line names can never be different sets.
|
|
189
|
+
*/
|
|
190
|
+
function coTenancyReport(mates, opts) {
|
|
191
|
+
const o = opts || {};
|
|
192
|
+
const peers = (mates || []).map((m) => describeTenant(m, o.writerId));
|
|
193
|
+
const n = peers.length;
|
|
194
|
+
return {
|
|
195
|
+
tree: o.tree || null,
|
|
196
|
+
branch: o.branch || null,
|
|
197
|
+
peers,
|
|
198
|
+
headline: n
|
|
199
|
+
? `${n} session${n === 1 ? '' : 's'} already running in this working tree`
|
|
200
|
+
: 'nothing else is running in this working tree',
|
|
201
|
+
// Stated once, here, so every surface that renders this report carries the same limit.
|
|
202
|
+
shared: 'you share one branch, one index and one set of files with '
|
|
203
|
+
+ (n === 1 ? 'it' : 'them'),
|
|
204
|
+
advisory: 'Claims are advisory: PMOS hosts your agent’s terminal, it does not sit between it and the files.',
|
|
205
|
+
escape: 'To work in parallel for real, start the session on a new branch with its own folder (⑂).',
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
76
209
|
/**
|
|
77
210
|
* The sentence a session gets when it starts in a tree somebody is already working in.
|
|
78
211
|
*
|
|
79
|
-
* It names the panes
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
212
|
+
* It names the panes AND what each of them is doing — a name alone tells the PM which pane, but not
|
|
213
|
+
* whether walking over to it is a good idea. `label` is the sidebar's own word for the session
|
|
214
|
+
* (working · quiet 12m · needs you · maybe blocked), passed in rather than re-derived, so this
|
|
215
|
+
* sentence and the sidebar cannot disagree. Rows with no label degrade to bare names rather than
|
|
216
|
+
* inventing a state.
|
|
217
|
+
*
|
|
218
|
+
* It states the ONE fact that makes this matter — a shared branch, index and file set — and names
|
|
219
|
+
* the way out that already exists, because a warning with no alternative is just a nag. Returns
|
|
220
|
+
* null when there is nobody there, so the caller never has to test for emptiness.
|
|
83
221
|
*/
|
|
84
222
|
function coTenancyNotice(mates, opts) {
|
|
85
223
|
const rows = mates || [];
|
|
86
224
|
if (!rows.length) return null;
|
|
87
225
|
const n = rows.length;
|
|
226
|
+
const rep = coTenancyReport(rows, opts);
|
|
88
227
|
const role = opts && opts.role === 'writer' ? 'writer' : 'reader';
|
|
89
228
|
const held = opts && opts.writerName;
|
|
90
229
|
const who = role === 'writer'
|
|
@@ -94,23 +233,40 @@ function coTenancyNotice(mates, opts) {
|
|
|
94
233
|
: held
|
|
95
234
|
? `“${held}” holds the writer claim`
|
|
96
235
|
: 'no session holds the writer claim';
|
|
97
|
-
return `${
|
|
98
|
-
+
|
|
99
|
-
+
|
|
100
|
-
+ '
|
|
236
|
+
return `${rep.headline} (${statedList(rows)}) — ${rep.shared}. ${who}. `
|
|
237
|
+
+ `${rep.advisory} ${rep.escape} `
|
|
238
|
+
+ `To join ${n === 1 ? 'it' : 'one of them'} instead of starting another, open it from + session — `
|
|
239
|
+
+ 'the chooser says what each one is doing and what ending it would cost.';
|
|
101
240
|
}
|
|
102
241
|
|
|
103
242
|
/**
|
|
104
243
|
* The same fact, written into the pane's own scrollback at spawn.
|
|
105
244
|
*
|
|
106
245
|
* The notice frame is a transient line in whichever client was listening; this one is part of the
|
|
107
|
-
* session's record, so re-opening the tab tomorrow still shows what it was told. Dim,
|
|
108
|
-
*
|
|
246
|
+
* session's record, so re-opening the tab tomorrow still shows what it was told. Dim, and it never
|
|
247
|
+
* claims anything was prevented.
|
|
248
|
+
*
|
|
249
|
+
* It is no longer ONE line. The sentence is followed by a row per peer carrying that peer's state
|
|
250
|
+
* and what ending it would cost — the same `peers` the chooser draws, in the same order, because
|
|
251
|
+
* the PM who scrolls back to this tomorrow is asking the identical question they asked at the
|
|
252
|
+
* chooser and deserves the identical answer. Bounded at four rows so a twelve-session tree cannot
|
|
253
|
+
* push the shell's first prompt off the screen; the sentence above already says the true count.
|
|
109
254
|
*/
|
|
255
|
+
const ADVISORY_ROWS = 4;
|
|
110
256
|
function paneAdvisory(mates, opts) {
|
|
111
257
|
const line = coTenancyNotice(mates, opts);
|
|
112
258
|
if (!line) return null;
|
|
113
|
-
|
|
259
|
+
const rep = coTenancyReport(mates, opts);
|
|
260
|
+
const shown = rep.peers.slice(0, ADVISORY_ROWS);
|
|
261
|
+
const rows = shown.map((p) => {
|
|
262
|
+
const who = `${p.name}${p.writer ? ' (writer)' : ''}`;
|
|
263
|
+
const doing = p.doing ? ` · ${p.doing}` : '';
|
|
264
|
+
return `\x1b[38;5;244m · ${who} — ${p.label}${doing}; ${p.risk.why}\x1b[0m\r\n`;
|
|
265
|
+
});
|
|
266
|
+
const more = rep.peers.length > shown.length
|
|
267
|
+
? [`\x1b[38;5;244m · …and ${rep.peers.length - shown.length} more, listed in + session\x1b[0m\r\n`]
|
|
268
|
+
: [];
|
|
269
|
+
return `\x1b[38;5;244m⑂ ${line}\x1b[0m\r\n` + rows.join('') + more.join('');
|
|
114
270
|
}
|
|
115
271
|
|
|
116
272
|
/**
|
|
@@ -126,4 +282,6 @@ function joinNotices(...parts) {
|
|
|
126
282
|
return kept.length ? kept.join(' · ') : null;
|
|
127
283
|
}
|
|
128
284
|
|
|
129
|
-
|
|
285
|
+
return { isLive, coTenants, writerOf, roleOf, coTenancyNotice, paneAdvisory, joinNotices,
|
|
286
|
+
nameList, statedList, ago, endRisk, describeTenant, coTenancyReport, ADVISORY_ROWS };
|
|
287
|
+
});
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.19.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"start": "node server.js",
|
|
12
12
|
"test": "node test.js && npm run test:modules",
|
|
13
|
-
"test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node no-network.test.js && node fixture-parity.test.mjs",
|
|
13
|
+
"test:modules": "node focus.test.js && node handoff.test.js && node repo-scan.test.js && node profiles.test.js && node no-network.test.js && node fixture-parity.test.mjs && node start-policy.test.js",
|
|
14
14
|
"test:ui": "node ui-test.js",
|
|
15
15
|
"test:parity": "node design-parity.js --states",
|
|
16
16
|
"test:all": "npm test && npm run test:panels && npm run test:ui && npm run test:parity && npm run test:offline",
|
|
@@ -29,6 +29,12 @@
|
|
|
29
29
|
holding different opinions about one session. Loaded before the module graph so a panel can
|
|
30
30
|
bind from window.PMOSSessionState rather than re-typing a word. -->
|
|
31
31
|
<script src="/lib/session-state.js"></script>
|
|
32
|
+
<!-- ONE module, TWO consumers, again (workspace-session-choice). Who shares a working tree, and
|
|
33
|
+
what ending one of those sessions would cost, is computed by the SERVER when it writes the
|
|
34
|
+
advisory into a new pane's scrollback and by the BROWSER when it draws the + session chooser.
|
|
35
|
+
Both read this file, so the sentence in the terminal and the sentence in the chooser cannot
|
|
36
|
+
drift apart. Classic script (a UMD setting window.PMOSTreeShare), like the two above. -->
|
|
37
|
+
<script src="/lib/tree-share.js"></script>
|
|
32
38
|
<!-- The phone's navigation reducer. DOM-free, loaded as a classic script (it is a UMD that sets
|
|
33
39
|
window.PMOSMobileNav) and required by test.js, so the whole screen domain is walkable in node
|
|
34
40
|
rather than only in a browser. pm/mobile.js reads it from the global; the module graph does not
|