create-pmos 0.17.0 → 0.18.1
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 +25 -0
- package/workspace/lib/file-mode-parse.js +12 -1
- package/workspace/lib/start-policy.js +118 -0
- package/workspace/lib/tree-share.js +129 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +2 -2
- package/workspace/public/pm/data.js +87 -3
- package/workspace/public/pm/panel-initiatives.js +7 -0
- package/workspace/public/pm/panel-tabs.js +147 -6
- package/workspace/public/pm/state.js +65 -6
- package/workspace/public/pm/tab-term.js +134 -18
- package/workspace/server.js +267 -8
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.18.1
|
|
337
|
+
|
|
338
|
+
**Three fixes to the workspace terminal, all of them about what the window knew about a session.**
|
|
339
|
+
Run `npx create-pmos@latest workspace`; the version bump means an existing install auto-upgrades.
|
|
340
|
+
|
|
341
|
+
- **Your terminal now uses its whole width.** Nothing ever told the shell how wide the browser
|
|
342
|
+
was. A session starts at 80 columns; the server stated its own size on attach rather than
|
|
343
|
+
adopting yours, and the browser only spoke up when a resize *changed* its grid — which happened
|
|
344
|
+
once, while the connection was still opening and there was no session to tell. So the process on
|
|
345
|
+
the other end kept wrapping at 80 columns inside a window three times that wide, and a
|
|
346
|
+
full-screen agent CLI painted into a box that was not there.
|
|
347
|
+
- **The window stops rebuilding itself while you type.** It repainted every panel every four
|
|
348
|
+
seconds whether or not anything had changed — on a project with a hundred-odd initiatives that is
|
|
349
|
+
a real stall, on a timer, next to a terminal you are typing into. It now repaints when something
|
|
350
|
+
it shows actually moves. Separately, the server rewrote every session's scrollback file to disk
|
|
351
|
+
every five seconds, synchronously, whether or not a byte had changed; it now writes only what
|
|
352
|
+
moved.
|
|
353
|
+
- **The scratchpad belongs to a session again.** It had become one shared buffer for the whole
|
|
354
|
+
page, so every terminal — in every repository — showed and overwrote the same text, and none of
|
|
355
|
+
it outlived the tab. Each session has its own pad again, saved on your machine, and it comes back
|
|
356
|
+
with the session.
|
|
357
|
+
- **Reopening the workspace brings your sessions back.** Open tabs are remembered and re-attach to
|
|
358
|
+
the exact session they were on, named on the tab so you can tell which one it is. Previously they
|
|
359
|
+
were forgotten on reload and had to be re-found by hand — and the re-finding matched on a session
|
|
360
|
+
*name*, which is not unique between repositories, so a tab in one project could silently attach
|
|
361
|
+
to a shell running in another. `+ session` also always starts a new session now, instead of
|
|
362
|
+
sometimes adopting one that was already running.
|
|
363
|
+
## What's new in 0.18.0
|
|
364
|
+
|
|
365
|
+
**Several sessions in one folder now say so.** Run `npx create-pmos@latest workspace`; the version
|
|
366
|
+
bump means an existing install auto-upgrades.
|
|
367
|
+
|
|
368
|
+
- **Starting a session where others are already running tells you what you are sharing.** Sessions
|
|
369
|
+
in one checkout share one branch, one index and one set of files — that was true before and
|
|
370
|
+
nothing said it. The new session's pane now names the sessions already there and points at the
|
|
371
|
+
alternative (a new branch in its own folder). It appears on the ordinary "+ session" path too,
|
|
372
|
+
where no git operation runs at all: the server had already worked out that other sessions would
|
|
373
|
+
be disturbed, and was throwing the answer away unless you happened to pick a branch.
|
|
374
|
+
- **It counts the right thing.** A repository registered at two paths — the project root and a
|
|
375
|
+
subdirectory of it, the ordinary shape when you add a product repo by path — is **one** checkout,
|
|
376
|
+
and its sessions are now told about each other; before, each was told it was alone. Two
|
|
377
|
+
*worktrees* of one repository are **two** checkouts and are deliberately not warned about, because
|
|
378
|
+
those are the sessions that isolation already made safe. A folder with no git says nothing, since
|
|
379
|
+
there is no index to collide over.
|
|
380
|
+
- **One session is the declared writer; the rest read.** The first live session in a checkout holds
|
|
381
|
+
the claim, and you can hand it to another (`POST /api/tree/claim`); `GET /api/tree/peers` reports
|
|
382
|
+
who holds it and who else is there. It moves only when you move it — never on a timer or an exit —
|
|
383
|
+
and when its holder exits the checkout reads *vacant* rather than promoting somebody at random.
|
|
384
|
+
- **Be clear about what that is: a declaration, not a lock.** The workspace hosts your agent's
|
|
385
|
+
terminal; it does not sit between the agent and the files, so nothing here prevents a write, a
|
|
386
|
+
checkout or a commit, and no session is ever refused because another one is there. It makes a
|
|
387
|
+
shared checkout visible. The discipline stays yours.
|
|
388
|
+
- **A folder you are working in can no longer be reclaimed out from under you.** Removing a
|
|
389
|
+
worktree already refused when sessions were running in it — but it counted only sessions in the
|
|
390
|
+
workspace registered at that exact folder, so a session running in a subdirectory of it was
|
|
391
|
+
invisible. It is counted now, and named in the refusal.
|
|
392
|
+
|
|
336
393
|
## What's new in 0.17.0
|
|
337
394
|
|
|
338
395
|
**Your board stops calling shipped work unstarted.** Run `npx create-pmos@latest workspace`; the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
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
|
@@ -155,6 +155,31 @@ the folder and **keeps the branch**; nothing is ever removed automatically. Git
|
|
|
155
155
|
between worktrees, so what a folder costs is a checkout of tracked files, not a second copy of the
|
|
156
156
|
repo.
|
|
157
157
|
|
|
158
|
+
## Several sessions in one tree: you are told, never stopped
|
|
159
|
+
|
|
160
|
+
Sessions in one working tree share **one branch, one index and one set of files**. Starting a
|
|
161
|
+
session in a tree that already has live ones now says so in the pane — naming them, and naming the
|
|
162
|
+
way out (⑂ a new branch in its own folder). It fires on the plain `+ session` path too, where no
|
|
163
|
+
git operation runs at all: the server already knew, and used to say nothing.
|
|
164
|
+
|
|
165
|
+
**The tree is the unit, not the folder you registered.** A repo registered twice — say `~/repo` and
|
|
166
|
+
`~/repo/packages/app`, the ordinary shape for a product repo added by path — is **one** tree, and
|
|
167
|
+
its sessions are told about each other. Two *worktrees* of one repo are **two** trees, and are not
|
|
168
|
+
warned about at all: those are the sessions isolation made safe. A folder with no git has no tree
|
|
169
|
+
and says nothing, because there is no index to collide over.
|
|
170
|
+
|
|
171
|
+
**The writer claim is a declaration, not a lock.** The first live session in a tree holds it; the
|
|
172
|
+
others read. `GET /api/tree/peers?ws=…` reports the tree, its holder and every session on it;
|
|
173
|
+
`POST /api/tree/claim {session}` hands it over. It moves **only** when you move it — never on a
|
|
174
|
+
timer, never on an exit. When the holder exits the tree reads *vacant*, and no survivor is
|
|
175
|
+
promoted, because which pane should be driving is your intention and not something to derive.
|
|
176
|
+
|
|
177
|
+
Be clear about what this buys: **PMOS hosts your agent's terminal, it does not sit between it and
|
|
178
|
+
the files.** Nothing here prevents a write, a checkout or a commit, and no spawn is ever refused
|
|
179
|
+
because someone else is in the tree. It makes a shared tree visible; the discipline is still yours.
|
|
180
|
+
(The role rides on `/api/sessions` rows as `tree` / `tree_role` / `tree_peers`; the tab-bar chip is
|
|
181
|
+
not wired yet.)
|
|
182
|
+
|
|
158
183
|
A workspace anywhere **inside** a repo knows its branch — a subdirectory (`repo/packages/app`)
|
|
159
184
|
counts, which matters for a product repo added by path. A folder with **no git** simply says so and
|
|
160
185
|
starts the session; nothing about git can block work in a repo that doesn't use it.
|
|
@@ -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 };
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Who shares a working tree with whom, and who is DECLARED to be driving it.
|
|
3
|
+
//
|
|
4
|
+
// The premise, stated once so nothing below has to hedge: PMOS hosts real PTYs and the agent CLI
|
|
5
|
+
// inside one is untouched (D62). There is nothing between an agent and the filesystem, so this
|
|
6
|
+
// module cannot prevent a write, a checkout or a commit — and never claims to. What it can do is
|
|
7
|
+
// compute the right SET of sessions that would collide, and carry one declaration about which of
|
|
8
|
+
// them the PM says is driving. Advisory, and every sentence it produces says so.
|
|
9
|
+
//
|
|
10
|
+
// The one idea worth holding: **three keys look like "which repo is this" and only one of them is
|
|
11
|
+
// the collision unit.**
|
|
12
|
+
// • the workspace id — a registered DIRECTORY. Too narrow: one repo can be registered twice
|
|
13
|
+
// (`~/repo` and `~/repo/packages/app`, the ordinary shape for a tenant repo added by path),
|
|
14
|
+
// and those two ids share one HEAD, one index, one set of files.
|
|
15
|
+
// • `repo_key` — the repository, i.e. its MAIN worktree. Too wide: it deliberately groups
|
|
16
|
+
// a repo's linked worktrees, which are exactly the sessions that are SAFELY isolated. Warning
|
|
17
|
+
// on those is how a warning gets trained away.
|
|
18
|
+
// • the working-tree top (`git rev-parse --show-toplevel`) — one HEAD, one index, one
|
|
19
|
+
// `index.lock`. This is the one. A folder with no git has no tree key, and therefore no
|
|
20
|
+
// co-tenants: there is no index to corrupt, so there is nothing to say.
|
|
21
|
+
//
|
|
22
|
+
// Pure functions over plain rows, on purpose: the server owns the git shell-outs and the session
|
|
23
|
+
// registry, and the rules are the part worth testing without either.
|
|
24
|
+
|
|
25
|
+
/** Live = a session the PM could still be driving. `exited` is a corpse and collides with nobody. */
|
|
26
|
+
function isLive(s) { return !!s && s.state !== 'exited'; }
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The live sessions sharing `tree`, excluding one id (normally the session asking).
|
|
30
|
+
* `treeOfWs` maps a workspace id → its working-tree top (null for a folder with no git).
|
|
31
|
+
* A null/empty `tree` matches nothing — "not in a repo" is never a group.
|
|
32
|
+
*/
|
|
33
|
+
function coTenants(sessions, treeOfWs, tree, exclude) {
|
|
34
|
+
if (!tree) return [];
|
|
35
|
+
const out = [];
|
|
36
|
+
for (const s of sessions || []) {
|
|
37
|
+
if (!isLive(s) || s.id === exclude) continue;
|
|
38
|
+
if (treeOfWs.get(s.ws) === tree) out.push(s);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Who holds the writer claim on `tree` — or null when nobody does.
|
|
45
|
+
*
|
|
46
|
+
* The claim is stored, but it is only BELIEVED while its holder is live: a claim is a statement
|
|
47
|
+
* about a running session, and a dead session cannot be driving anything. Deriving the answer this
|
|
48
|
+
* way is what makes "the holder exited" read as **vacant** rather than as a stale name, without a
|
|
49
|
+
* cleanup pass having to be correct for the UI to be honest.
|
|
50
|
+
*
|
|
51
|
+
* Note what this deliberately does NOT do: promote the oldest survivor. Which pane should drive is
|
|
52
|
+
* the PM's intention, not an observable, and a derived answer must never impersonate a decision.
|
|
53
|
+
*/
|
|
54
|
+
function writerOf(claims, treeOfWs, sessions, tree) {
|
|
55
|
+
if (!tree) return null;
|
|
56
|
+
const held = claims.get(tree);
|
|
57
|
+
if (!held) return null;
|
|
58
|
+
const s = (sessions || []).find((x) => x && x.id === held);
|
|
59
|
+
if (!isLive(s) || treeOfWs.get(s.ws) !== tree) return null;
|
|
60
|
+
return held;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 'writer' | 'reader' for a session in a tree; null when it is not in one (no git → no role). */
|
|
64
|
+
function roleOf(sessionId, tree, writerId) {
|
|
65
|
+
if (!tree) return null;
|
|
66
|
+
return sessionId === writerId ? 'writer' : 'reader';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Name a handful of panes without letting a 12-session tree produce a paragraph. */
|
|
70
|
+
function nameList(rows, cap = 3) {
|
|
71
|
+
const names = (rows || []).map((s) => `“${s.name}”`);
|
|
72
|
+
if (names.length <= cap) return names.join(', ');
|
|
73
|
+
return `${names.slice(0, cap).join(', ')} and ${names.length - cap} more`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The sentence a session gets when it starts in a tree somebody is already working in.
|
|
78
|
+
*
|
|
79
|
+
* It names the panes (a bare count tells the PM nothing they can act on), states the ONE fact that
|
|
80
|
+
* makes this matter — a shared branch, index and file set — and names the way out that already
|
|
81
|
+
* exists, because a warning with no alternative is just a nag. Returns null when there is nobody
|
|
82
|
+
* there, so the caller never has to test for emptiness.
|
|
83
|
+
*/
|
|
84
|
+
function coTenancyNotice(mates, opts) {
|
|
85
|
+
const rows = mates || [];
|
|
86
|
+
if (!rows.length) return null;
|
|
87
|
+
const n = rows.length;
|
|
88
|
+
const role = opts && opts.role === 'writer' ? 'writer' : 'reader';
|
|
89
|
+
const held = opts && opts.writerName;
|
|
90
|
+
const who = role === 'writer'
|
|
91
|
+
// Vacant tree: this session took the claim. Say so — the PM should know a declaration was made
|
|
92
|
+
// on their behalf, not discover it later in a list.
|
|
93
|
+
? 'nobody had claimed it, so this session now holds the writer claim'
|
|
94
|
+
: held
|
|
95
|
+
? `“${held}” holds the writer claim`
|
|
96
|
+
: 'no session holds the writer claim';
|
|
97
|
+
return `${n} session${n === 1 ? '' : 's'} already running in this working tree (${nameList(rows)}) — `
|
|
98
|
+
+ `you share one branch, one index and one set of files with ${n === 1 ? 'it' : 'them'}. ${who}. `
|
|
99
|
+
+ 'Claims are advisory: PMOS hosts your agent’s terminal, it does not sit between it and the '
|
|
100
|
+
+ 'files. To work in parallel for real, start the session on a new branch with its own folder (⑂).';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The same fact, written into the pane's own scrollback at spawn.
|
|
105
|
+
*
|
|
106
|
+
* 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, one line,
|
|
108
|
+
* and it never claims anything was prevented.
|
|
109
|
+
*/
|
|
110
|
+
function paneAdvisory(mates, opts) {
|
|
111
|
+
const line = coTenancyNotice(mates, opts);
|
|
112
|
+
if (!line) return null;
|
|
113
|
+
return `\x1b[38;5;244m⑂ ${line}\x1b[0m\r\n`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Carry two non-fatal truths on a channel that has only ever carried one.
|
|
118
|
+
*
|
|
119
|
+
* `spawnWithBranch` returns a single `notice` string and, until now, at most one of its facts could
|
|
120
|
+
* be set at a time. A branch notice and a co-tenancy notice can both be true of the same spawn, and
|
|
121
|
+
* the second must not evict the first — dropping one would make the wire format quietly lossy in
|
|
122
|
+
* exactly the situation the PM most needs both.
|
|
123
|
+
*/
|
|
124
|
+
function joinNotices(...parts) {
|
|
125
|
+
const kept = parts.filter((p) => p != null && String(p).trim() !== '');
|
|
126
|
+
return kept.length ? kept.join(' · ') : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { isLive, coTenants, writerOf, roleOf, coTenancyNotice, paneAdvisory, joinNotices, nameList };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.18.1",
|
|
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.18.1",
|
|
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",
|
|
@@ -552,14 +552,22 @@ async function loadBoard(prod) {
|
|
|
552
552
|
/** Publish every loaded product's board and records into state, in one patch. */
|
|
553
553
|
function publishBoards() {
|
|
554
554
|
const board = {};
|
|
555
|
+
/* Product NAME → workspace id, for every registered repository — not only the selected one.
|
|
556
|
+
* panel 4 keeps every open terminal live, including those belonging to a repository the PM is not
|
|
557
|
+
* currently looking at, and a parked tab's session must be resolved inside ITS OWN repository.
|
|
558
|
+
* Scoping it by the selected workspace is how a tab in one repo attached to a shell in another
|
|
559
|
+
* (tab-term.js `wsForTab`). */
|
|
560
|
+
const productWs = {};
|
|
555
561
|
let records = {};
|
|
556
562
|
for (const name of productOrder) {
|
|
557
563
|
const p = products.get(name);
|
|
558
|
-
if (!p
|
|
564
|
+
if (!p) continue;
|
|
565
|
+
if (p.ws) productWs[name] = p.ws;
|
|
566
|
+
if (!p.board) continue;
|
|
559
567
|
board[name] = p.board;
|
|
560
568
|
records = { ...records, ...p.records };
|
|
561
569
|
}
|
|
562
|
-
setState({ board, records: { ...records, ...(hydrated) } });
|
|
570
|
+
setState({ board, productWs, records: { ...records, ...(hydrated) } });
|
|
563
571
|
}
|
|
564
572
|
|
|
565
573
|
/* Hydrated (fully derived) records live apart from the light ones so that a board reload cannot
|
|
@@ -617,12 +625,48 @@ function currentProductName() {
|
|
|
617
625
|
*/
|
|
618
626
|
|
|
619
627
|
let pollTimer = null;
|
|
628
|
+
let sessionDigest = null;
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* WHAT THE SURFACE ACTUALLY RENDERS from a session row, as one string.
|
|
632
|
+
*
|
|
633
|
+
* Every `setState` repaints all four panels, and panel 2 rebuilds a board that is 138 initiatives
|
|
634
|
+
* deep on this repository. Publishing the poll unconditionally therefore tore down and rebuilt the
|
|
635
|
+
* whole window every four seconds, forever, whether or not anything had changed — which is a
|
|
636
|
+
* hundreds-of-milliseconds stall on the main thread, on a cadence, next to a terminal the PM is
|
|
637
|
+
* typing into. That is the jitter: input queued behind a repaint nobody asked for.
|
|
638
|
+
*
|
|
639
|
+
* The rows themselves change constantly (`lastOutputAt` moves with every byte an agent writes), so
|
|
640
|
+
* comparing the payload is useless. What is compared is the PROJECTION the panels read — id, name,
|
|
641
|
+
* workspace, derived state, the quiet reading ROUNDED THE WAY THE LABEL ROUNDS IT (minutes), and
|
|
642
|
+
* the fields the sidebar prints. A working agent produces an identical projection minute after
|
|
643
|
+
* minute, so the window stays still while it works, and repaints the moment something it shows
|
|
644
|
+
* actually moves.
|
|
645
|
+
*/
|
|
646
|
+
function sessionProjection(rows) {
|
|
647
|
+
return (rows || []).map((s) => [
|
|
648
|
+
s.id, s.ws, s.name, s.agent, s.state, s.shown, s.note || '', s.cwd || '',
|
|
649
|
+
s.profile_id || '', s.profile_line || '',
|
|
650
|
+
// the label's own resolution, not the millisecond behind it
|
|
651
|
+
s.shown === 'quiet' && Number.isFinite(s.quietMs) ? Math.max(1, Math.round(s.quietMs / 60000)) : '',
|
|
652
|
+
// Joined on U+0000, fields and rows both. A separator that cannot occur in any of these
|
|
653
|
+
// values is what stops two different lists folding into one identical string and
|
|
654
|
+
// suppressing a repaint that was real.
|
|
655
|
+
].join('\u0000')).join('\u0000\u0000');
|
|
656
|
+
}
|
|
620
657
|
|
|
621
658
|
async function pollSessions() {
|
|
622
659
|
if (FIXTURE) return;
|
|
623
660
|
const out = await getJson('/api/sessions');
|
|
624
661
|
if (out && Array.isArray(out.sessions)) {
|
|
625
|
-
|
|
662
|
+
const digest = sessionProjection(out.sessions);
|
|
663
|
+
const count = liveCount(out.sessions);
|
|
664
|
+
// A poll that found nothing new is not a render. `runningCount` is compared too: it folds in
|
|
665
|
+
// `extraSess`, so a session started in this window still publishes on the very next poll.
|
|
666
|
+
if (digest !== sessionDigest || count !== state.runningCount) {
|
|
667
|
+
sessionDigest = digest;
|
|
668
|
+
setState({ sessions: out.sessions, runningCount: count });
|
|
669
|
+
}
|
|
626
670
|
}
|
|
627
671
|
schedulePoll();
|
|
628
672
|
}
|
|
@@ -817,6 +861,46 @@ export function startSession(slug, sess) {
|
|
|
817
861
|
setTimeout(pollSessions, 800);
|
|
818
862
|
}
|
|
819
863
|
|
|
864
|
+
/* ── the scratchpad ─────────────────────────────────────────────────────────────────────────────
|
|
865
|
+
* The pad is PER SESSION, and the server has always stored it that way: `/api/scratchpad` is keyed
|
|
866
|
+
* by the server-issued session id (workspace-scoped, one file per session, deleted with it). What
|
|
867
|
+
* was lost in the four-panel port was the CLIENT half — the pad became one in-memory string for the
|
|
868
|
+
* whole page, so every tab in every repository showed, and overwrote, the same text.
|
|
869
|
+
*
|
|
870
|
+
* These two functions are the whole restoration. The key is `sess:<id>` or `ws:<id>`; the panel
|
|
871
|
+
* derives it, this file only speaks the two shapes the endpoint accepts.
|
|
872
|
+
*/
|
|
873
|
+
|
|
874
|
+
function padParams(key) {
|
|
875
|
+
const k = String(key || '');
|
|
876
|
+
if (k.startsWith('sess:')) return { session: k.slice(5) };
|
|
877
|
+
if (k.startsWith('ws:')) return { ws: k.slice(3) };
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** panel-tabs.js — the stored text for one pad, or '' when there is none (and on any failure). */
|
|
882
|
+
export async function loadScratchpad(key) {
|
|
883
|
+
if (FIXTURE) return '';
|
|
884
|
+
const params = padParams(key);
|
|
885
|
+
if (!params) return '';
|
|
886
|
+
const out = await getJson('/api/scratchpad', params);
|
|
887
|
+
return out && out.ok !== false && typeof out.text === 'string' ? out.text : '';
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* panel-tabs.js — write one pad. A failure is reported to the console and NOT surfaced as a lost
|
|
892
|
+
* buffer: the text stays in `state.scratchPads`, so the PM keeps what they typed for this page even
|
|
893
|
+
* when the disk write refused.
|
|
894
|
+
*/
|
|
895
|
+
export async function saveScratchpad(key, text) {
|
|
896
|
+
if (FIXTURE) return { ok: true };
|
|
897
|
+
const params = padParams(key);
|
|
898
|
+
if (!params) return { ok: false, error: 'no session or workspace to file this pad under' };
|
|
899
|
+
const out = await postJson('/api/scratchpad', { ...params, text });
|
|
900
|
+
if (out && out.ok === false) console.warn('[pm] the scratchpad was NOT saved: ' + out.error);
|
|
901
|
+
return out;
|
|
902
|
+
}
|
|
903
|
+
|
|
820
904
|
/** tab-doc.js — `readDoc(path)`. The raw document body, or a rejected promise carrying the reason. */
|
|
821
905
|
export async function readDoc(path) {
|
|
822
906
|
if (FIXTURE) return '';
|
|
@@ -131,6 +131,13 @@ export function openTerm(slug, sess) {
|
|
|
131
131
|
openTab({
|
|
132
132
|
id: 'term:' + slug + ':' + sess.name, kind: 'term', title: sess.name, slug,
|
|
133
133
|
repo: slugProduct()[slug], sess: sess.name, state: sess.st, profile: sess.profile || 'claude-work',
|
|
134
|
+
/* The SERVER's session id, when the row that was clicked carries one. This is the exact join:
|
|
135
|
+
* with it, the terminal attaches to precisely the PTY the PM pointed at, and a reload attaches
|
|
136
|
+
* to the same one again. Without it the tab falls back to matching by NAME, and names repeat
|
|
137
|
+
* across repositories — which is how a click in one repo opened another repo's shell.
|
|
138
|
+
* `spawn` is the opposite instruction, set only by `+ session`: start one, never adopt one. */
|
|
139
|
+
sessionId: (sess && sess.id) || null,
|
|
140
|
+
spawn: !!(sess && sess.spawn),
|
|
134
141
|
});
|
|
135
142
|
}
|
|
136
143
|
|