create-pmos 0.8.0 → 0.9.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 CHANGED
@@ -333,6 +333,30 @@ 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.9.0
337
+
338
+ **The Terminal Workspace now tells you whose turn it is.** The optional GUI got this release's real
339
+ work: it reads each session's transcript and shows, per session, whether the agent is still working
340
+ or **waiting on you** — so a wall of terminals becomes a queue you can triage at a glance instead of
341
+ a grid you have to click through.
342
+
343
+ - **Whose-turn, read from the actual transcript.** Each session row and tab carries its state —
344
+ agent-working vs waiting-on-you — derived from the real conversation, not a guess. The sessions
345
+ that need a decision surface; the ones still running recede.
346
+ - **A real design system, not a dev-tool skin.** The workspace was reskinned on a proper palette
347
+ with its own typography, and the fonts ship *inside* the package — nothing is fetched at runtime,
348
+ so it looks the same offline and behind a proxy.
349
+ - **Your git branch is in the frame.** Each session shows the branch it is on and lets you switch,
350
+ so `initiative/<id>` work and its worktree are visible where you are working, not a `git status`
351
+ away.
352
+ - **The router now makes you write the answers down.** `pmos-router` gained a step: the intake
353
+ answers (does this need its own instance? has this been rejected before?) get recorded on the
354
+ initiative, so a routing decision is enforceable later instead of evaporating the moment it's made.
355
+
356
+ All of this rides in `npx create-pmos@latest workspace`. The file-mode kit itself is unchanged and
357
+ still dependency-free; `npx create-pmos@latest --update` brings the router change into an existing
358
+ kit and never touches your `state/`, `planning/`, or `okf/product/` records.
359
+
336
360
  ## What's new in 0.8.0
337
361
 
338
362
  **The document review is gone. There is a document editor instead.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.8.0",
3
+ "version": "0.9.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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@f56d5eb
1
+ kernel-source: pmos@bb26298
2
2
  assembled: 2026-07-28
3
3
  mode: vendor
@@ -68,6 +68,28 @@ router is the bug (fix it via the weekly harness scan).
68
68
  branch `initiative/<id>`, and the human Acceptance Gate at the end (D12) — the router never bypasses
69
69
  [working-in-pmos](/okf/core/playbooks/working-in-pmos.md).
70
70
 
71
+ ## Step 5 — WRITE THE ANSWERS DOWN (this is what makes any of it enforceable)
72
+
73
+ Steps 0.3 and 0.4 used to be asked aloud and then discarded, and the cost was exactly what you would
74
+ predict: nothing could check that a *needs-an-instance* answer was followed by a prototype, or that
75
+ an *existing surface* was explained before it was specified. An unrecorded answer is an
76
+ unenforceable one. So both land on the initiative record, as a **leading token plus your reasoning**:
77
+
78
+ ```yaml
79
+ specimen_test: "needs-instance — no description settles whether …" # or "description-judgeable — …"
80
+ predecessor: <prototype-initiative-id> # required when the answer is needs-instance
81
+ surface_check: "existing — the Initiative Gate" # or "greenfield — …"
82
+ explainer_path: planning/explainers/<surface>-explainer.md # required when it is existing
83
+ ```
84
+
85
+ The gate ([`scripts/workflow-state.js`](/scripts/workflow-state.js)) reads the **token** and the
86
+ **ordering of the paths it implies** — the prose is for the PM. It fails a PR whose PRD first
87
+ appeared before its prototype brief (**O3**) or before its surface explainer (**O4**). It does not
88
+ judge whether the answer was *right*: no deterministic check can decide that, and over-firing the
89
+ specimen test twice running stays a D41 tripwire on the test's wording, not a gate condition.
90
+
91
+ Check your position at any time with `node scripts/workflow-state.js <id>`.
92
+
71
93
  ## Edge rules
72
94
 
73
95
  - **Two shapes at once** (an idea that is also a bet): sharpness first, then the brief-offer, then
@@ -70,6 +70,28 @@ router is the bug (fix it via the weekly harness scan).
70
70
  branch `initiative/<id>`, and the human Acceptance Gate at the end (D12) — the router never bypasses
71
71
  [working-in-pmos](/okf/core/playbooks/working-in-pmos.md).
72
72
 
73
+ ## Step 5 — WRITE THE ANSWERS DOWN (this is what makes any of it enforceable)
74
+
75
+ Steps 0.3 and 0.4 used to be asked aloud and then discarded, and the cost was exactly what you would
76
+ predict: nothing could check that a *needs-an-instance* answer was followed by a prototype, or that
77
+ an *existing surface* was explained before it was specified. An unrecorded answer is an
78
+ unenforceable one. So both land on the initiative record, as a **leading token plus your reasoning**:
79
+
80
+ ```yaml
81
+ specimen_test: "needs-instance — no description settles whether …" # or "description-judgeable — …"
82
+ predecessor: <prototype-initiative-id> # required when the answer is needs-instance
83
+ surface_check: "existing — the Initiative Gate" # or "greenfield — …"
84
+ explainer_path: planning/explainers/<surface>-explainer.md # required when it is existing
85
+ ```
86
+
87
+ The gate ([`scripts/workflow-state.js`](/scripts/workflow-state.js)) reads the **token** and the
88
+ **ordering of the paths it implies** — the prose is for the PM. It fails a PR whose PRD first
89
+ appeared before its prototype brief (**O3**) or before its surface explainer (**O4**). It does not
90
+ judge whether the answer was *right*: no deterministic check can decide that, and over-firing the
91
+ specimen test twice running stays a D41 tripwire on the test's wording, not a gate condition.
92
+
93
+ Check your position at any time with `node scripts/workflow-state.js <id>`.
94
+
73
95
  ## Edge rules
74
96
 
75
97
  - **Two shapes at once** (an idea that is also a bet): sharpness first, then the brief-offer, then
@@ -94,6 +94,34 @@ tokens, JWTs, `Authorization:` values, api keys): scrollback holds whatever the
94
94
  so D08's never-log-headers discipline applies to what this hands back. Matching runs against
95
95
  the *scrubbed* text, so the endpoint cannot be used to grep a secret out.
96
96
 
97
+ ## Branches: a new one doesn't move the others
98
+
99
+ Starting a session, the branch control is **optional** — the default is "stay on current" and it
100
+ performs no git operation at all. When you do pick one:
101
+
102
+ - **`+ new branch…`** can give that branch its **own working tree**, at
103
+ `../<repo>-worktrees/<branch>` (a sibling of the repo — nothing appears inside it, nothing for
104
+ `.gitignore` to chase), registered as its own workspace. Every session already running stays
105
+ exactly where it was. The folder is named in the form before you commit to it, and again in the
106
+ footer once it exists.
107
+ - **That's the default only when there are sessions to protect.** Alone in a workspace a plain
108
+ checkout disturbs nobody, so no folder is made. The checkbox is yours to flip either way.
109
+ - **Unchecking "give it its own folder"** is the shared-tree checkout: this repo moves, and so does
110
+ every session in it. The hint counts them, and only warns when something really would move.
111
+ - A branch can live in **one** working tree at a time. One already open elsewhere is labelled with
112
+ its location in the picker, rather than failing later.
113
+
114
+ **⑂ worktrees** in the sidebar lists every working tree the repo has, and removes the ones that are
115
+ safe to remove. A removal is refused — with the reason — when the tree has sessions running in it,
116
+ uncommitted changes, or commits that no other branch, remote or tag holds. Removing one reclaims
117
+ the folder and **keeps the branch**; nothing is ever removed automatically. Git history is shared
118
+ between worktrees, so what a folder costs is a checkout of tracked files, not a second copy of the
119
+ repo.
120
+
121
+ A workspace anywhere **inside** a repo knows its branch — a subdirectory (`repo/packages/app`)
122
+ counts, which matters for a product repo added by path. A folder with **no git** simply says so and
123
+ starts the session; nothing about git can block work in a repo that doesn't use it.
124
+
97
125
  ## Boundaries (honest v1 seams)
98
126
  - **Server binds 127.0.0.1** — the D62 hosted-PTY-bridge rejection stands; a tailnet/phone client
99
127
  attaches to the same local server, never a public endpoint.
@@ -114,4 +114,59 @@ function radar({ stateDir, snapshotDir }) {
114
114
  } };
115
115
  }
116
116
 
117
- module.exports = { metrics, radar, computeMetricsFromRuns, loadJson, loadJsonl };
117
+ /*
118
+ * ── workflow: where each initiative is, what is next, what is blocking ────────────────────────
119
+ *
120
+ * The one surface that answers "where am I in the loop" without the PM reconstructing it from
121
+ * files. It renders the SAME ladder the CLI and the Initiative Gate print, from the same engine —
122
+ * `scripts/workflow-state.js` — so the panel can never disagree with what CI is about to say.
123
+ *
124
+ * The engine is the one BUNDLED WITH THE WORKSPACE, pointed at the opened repo via its `root`
125
+ * option. It is deliberately not `require`d out of the opened directory: the engine only reads
126
+ * files and runs `git -C <root> log`, so no code from a workspace the PM merely opened is ever
127
+ * executed. An install that does not carry the engine gets an honest error, never a blank panel.
128
+ */
129
+ function workflow({ root }) {
130
+ if (!root) return { ok: false, error: 'no workspace root resolved for the workflow surface' };
131
+ let engine;
132
+ try { engine = require(path.join(__dirname, '..', '..', 'scripts', 'workflow-state.js')); }
133
+ catch {
134
+ return { ok: false, error: 'the workflow engine (scripts/workflow-state.js) is not present in this install — the ladder cannot be derived here' };
135
+ }
136
+ let ids;
137
+ try { ids = engine.listInitiatives(root); }
138
+ catch (e) { return { ok: false, error: 'could not list initiative records: ' + String((e && e.message) || 'error').slice(0, 120) }; }
139
+ if (!ids.length) return { ok: true, empty: true, source: 'file-mode' };
140
+
141
+ // One commit range for the whole board — resolving it per initiative would re-shell git for each.
142
+ const range = engine.commitRange(root);
143
+ const rows = ids.map((id) => {
144
+ try { return engine.computeWorkflow(id, { root, range }); }
145
+ catch (e) { return { ok: false, id, error: String((e && e.message) || 'derivation failed').slice(0, 160) }; }
146
+ });
147
+ // Same `inFlight` the CLI and the gate use — the panel must not invent its own idea of what is
148
+ // open. Filtering by `legacy` here was a real bug: on main every record is legacy, so the panel
149
+ // read "0 in flight" against 93 initiatives, blank at exactly the moment someone opens it.
150
+ const { shown, hidden, total } = engine.inFlight(rows);
151
+ const shape = (r) => (r.ok ? {
152
+ id: r.id, title: r.title, lane: r.lane, stage: r.stage, legacy: r.legacy,
153
+ in_change: r.in_change, anchor: r.anchor,
154
+ record: r.record, blockers: r.blockers, next: r.next,
155
+ steps: r.steps.map((s) => ({ key: s.key, status: s.status, evidence: s.evidence, next: s.next, blocking: s.blocking, rule: s.rule })),
156
+ } : { id: r.id, error: r.error });
157
+ return {
158
+ ok: true, empty: false, source: 'file-mode',
159
+ data: {
160
+ live: shown.map(shape),
161
+ hidden, // reported on the client — a silent cap reads as "that is all"
162
+ total,
163
+ accepted: rows.filter((r) => r.ok && r.accepted).length,
164
+ unreadable: rows.filter((r) => !r.ok).map(shape),
165
+ blocked: shown.filter((r) => r.ok && r.blockers.length).length,
166
+ degraded: range.ok ? null : range.reason,
167
+ enforced_from: engine.ENFORCED_FROM,
168
+ },
169
+ };
170
+ }
171
+
172
+ module.exports = { metrics, radar, workflow, computeMetricsFromRuns, loadJson, loadJsonl };
@@ -1,25 +1,51 @@
1
1
  'use strict';
2
2
  // Read-mostly git helpers for the workspace (branch awareness + select/create at session start).
3
3
  // Everything here shells `git` via spawnSync with an ARGS ARRAY — never a shell string — so no
4
- // caller input is ever interpolated into a command line. A workspace's working tree is SHARED by
5
- // every session in it (D62), so a branch is a property of the REPO, not a session: switching moves
6
- // every session in that repo. Callers surface that truth; this module never pretends otherwise.
4
+ // caller input is ever interpolated into a command line.
5
+ //
6
+ // Two truths this module exists to keep straight:
7
+ // 1. A workspace's working tree is SHARED by every session in it (D62), so `checkout` is a
8
+ // property of the REPO, not a session: switching moves every session in that repo — and every
9
+ // OTHER workspace registered under the same repo too. Callers surface that; we never pretend.
10
+ // 2. The way OUT of that shared-tree constraint is a linked worktree: `addWorktree` gives a
11
+ // branch its own directory, so a new session can start on a new branch while every running
12
+ // session stays exactly where it was. That is the isolated path, and it is the default the
13
+ // UI offers for "+ new branch".
14
+ //
15
+ // "Is this a repo?" is answered by `git rev-parse`, NOT by looking for a `.git` entry: a workspace
16
+ // registered at a SUBDIRECTORY of a repo (common for a tenant product repo added by path) is
17
+ // inside git, and reporting "not a git repository" there was a lie that cost the PM real time.
7
18
  const fs = require('fs');
8
19
  const path = require('path');
9
20
  const { spawnSync } = require('child_process');
10
21
 
11
22
  const GIT_TIMEOUT = 5000; // never let a slow/hung repo (huge tree, network remote) block the server
12
23
 
13
- function isRepo(root) {
14
- // a normal repo has a `.git` dir; a linked worktree has a `.git` FILE — both count.
15
- try { return !!root && fs.existsSync(path.join(root, '.git')); } catch { return false; }
16
- }
24
+ function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
17
25
 
18
26
  function git(root, args) {
19
27
  const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: GIT_TIMEOUT });
20
28
  return { code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim(), failed: !!r.error };
21
29
  }
22
30
 
31
+ // The repo's top-level directory, or null when `root` is not inside a repo. Works from any depth,
32
+ // which is the whole point: a workspace at repo/packages/app is still in git.
33
+ function repoRoot(root) {
34
+ if (!root || !isDir(root)) return null;
35
+ const r = git(root, ['rev-parse', '--show-toplevel']);
36
+ if (r.failed || r.code !== 0 || !r.out) return null;
37
+ const top = r.out.split('\n')[0].trim();
38
+ return top ? path.resolve(top) : null;
39
+ }
40
+
41
+ function isRepo(root) {
42
+ if (!root) return false;
43
+ if (repoRoot(root)) return true;
44
+ // git absent from PATH: fall back to the on-disk marker so we don't call a real repo "not a
45
+ // repo". A normal repo has a `.git` dir; a linked worktree has a `.git` FILE — both count.
46
+ try { return fs.existsSync(path.join(root, '.git')); } catch { return false; }
47
+ }
48
+
23
49
  // git check-ref-format for a BRANCH name, re-implemented defensively so nothing dubious ever
24
50
  // reaches `git` (defense in depth — `git` itself is the final authority and rejects the rest).
25
51
  // Rejects: empty, whitespace/control, the special chars ~ ^ : ? * [ \ , the sequences .. and @{,
@@ -38,25 +64,42 @@ function validBranchName(name) {
38
64
  return true;
39
65
  }
40
66
 
41
- // { ok, current, branches[], detached, dirty }. A non-repo or a failed git returns ok:false with a
42
- // reason — NEVER a fabricated branch (a wrong branch chip is worse than none; anti-drift).
43
- function branchInfo(root) {
44
- if (!isRepo(root)) return { ok: false, error: 'not a git repository' };
45
- const head = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
46
- if (head.failed) return { ok: false, error: 'git not available' };
47
- if (head.code !== 0) return { ok: false, error: head.err || 'could not read HEAD' };
48
- const detached = head.out === 'HEAD';
49
- const current = detached ? null : head.out;
50
- const refs = git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']);
51
- const branches = refs.code === 0 && refs.out ? refs.out.split('\n').filter(Boolean) : [];
52
- const status = git(root, ['status', '--porcelain']);
53
- const dirty = status.code === 0 ? status.out.length > 0 : false;
54
- return { ok: true, current, branches, detached, dirty };
67
+ // Every working tree attached to this repo: [{ dir, branch|null }]. Used for two honest answers —
68
+ // "this branch is already open over there" (git will refuse a second checkout of it, so the picker
69
+ // says so instead of letting the PM hit a raw git error) and "here is where an isolated session
70
+ // would land".
71
+ function worktrees(root) {
72
+ const top = repoRoot(root);
73
+ return top ? worktreesAt(top) : [];
74
+ }
75
+ // same, for a caller that already resolved the top (saves a `git rev-parse` per call)
76
+ function worktreesAt(top) {
77
+ const r = git(top, ['worktree', 'list', '--porcelain']);
78
+ if (r.failed || r.code !== 0 || !r.out) return [];
79
+ const out = [];
80
+ let cur = null;
81
+ for (const line of r.out.split('\n')) {
82
+ if (line.startsWith('worktree ')) { cur = { dir: line.slice(9).trim(), branch: null }; out.push(cur); }
83
+ else if (cur && line.startsWith('branch ')) cur.branch = line.slice(7).trim().replace(/^refs\/heads\//, '');
84
+ }
85
+ return out;
55
86
  }
56
87
 
57
- // Check out an existing branch, or create one with -b. Confined to `root` by cwd; the name is
58
- // validated first so nothing malformed reaches git. On any failure returns { ok:false, error } and
59
- // leaves the caller to NOT spawn a session on the wrong branch (a failed checkout must never spawn).
88
+ // Where an isolated worktree lands: a sibling of the repo, gathered under one `<repo>-worktrees/`
89
+ // folder. Deliberately OUTSIDE the repo — nothing new appears inside the tree, so no `git status`
90
+ // noise and nothing for .gitignore to chase. The path is derived from a name already passed
91
+ // through validBranchName (charset A–Za–z0–9._/-, no `..`, no leading `.`), so the only
92
+ // transformation needed is '/' → '-' to keep it one directory segment.
93
+ function worktreePath(root, branch) {
94
+ const top = repoRoot(root);
95
+ if (!top || !validBranchName(branch)) return null;
96
+ return path.join(path.dirname(top), path.basename(top) + '-worktrees', branch.replace(/\//g, '-'));
97
+ }
98
+
99
+ // Check out an existing branch, or create one with -b. THIS MOVES THE WHOLE REPO — every session
100
+ // in every workspace under it. Confined to `root` by cwd; the name is validated first so nothing
101
+ // malformed reaches git. On any failure returns { ok:false, error } and leaves the caller to NOT
102
+ // spawn a session on the wrong branch (a failed checkout must never spawn).
60
103
  function checkout(root, branch, { create = false } = {}) {
61
104
  if (!isRepo(root)) return { ok: false, error: 'not a git repository' };
62
105
  if (!validBranchName(branch)) return { ok: false, error: 'invalid branch name' };
@@ -68,4 +111,133 @@ function checkout(root, branch, { create = false } = {}) {
68
111
  return { ok: true, current: info.ok ? info.current : branch };
69
112
  }
70
113
 
71
- module.exports = { isRepo, validBranchName, branchInfo, checkout };
114
+ // Put `branch` in its OWN working tree, so a session can start on it while every already-running
115
+ // session stays untouched. `create` makes the branch (`-b`); otherwise the branch must exist and
116
+ // must not already be checked out somewhere else (git refuses — we return that plainly).
117
+ // Returns { ok, dir, branch, reused } or { ok:false, error }.
118
+ function addWorktree(root, branch, { create = false } = {}) {
119
+ const top = repoRoot(root);
120
+ if (!top) return { ok: false, error: 'not a git repository' };
121
+ if (!validBranchName(branch)) return { ok: false, error: 'invalid branch name' };
122
+
123
+ // Already checked out somewhere? Say where, rather than letting git's message be the first the
124
+ // PM sees — and if it is exactly the worktree we would have made, reuse it instead of failing.
125
+ const dir = worktreePath(top, branch);
126
+ const existing = worktreesAt(top).find((w) => w.branch === branch);
127
+ if (existing) {
128
+ if (create) return { ok: false, error: `branch '${branch}' already exists (checked out at ${existing.dir})` };
129
+ if (path.resolve(existing.dir) === path.resolve(dir)) return { ok: true, dir, branch, reused: true };
130
+ return { ok: false, error: `branch '${branch}' is already checked out at ${existing.dir} — git allows one working tree per branch` };
131
+ }
132
+ if (fs.existsSync(dir)) return { ok: false, error: `${dir} already exists — move or remove it, or pick another branch name` };
133
+
134
+ try { fs.mkdirSync(path.dirname(dir), { recursive: true }); }
135
+ catch (e) { return { ok: false, error: `cannot create ${path.dirname(dir)}: ${e.code || e.message}` }; }
136
+
137
+ const args = create ? ['worktree', 'add', '-b', branch, dir] : ['worktree', 'add', dir, branch];
138
+ const r = git(top, args);
139
+ if (r.failed) return { ok: false, error: 'git not available' };
140
+ if (r.code !== 0) {
141
+ // never leave a half-made directory behind to block the next attempt
142
+ try { if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir); } catch {}
143
+ return { ok: false, error: (r.err || r.out || 'worktree add failed').split('\n').filter(Boolean).pop().slice(0, 200) };
144
+ }
145
+ return { ok: true, dir, branch, reused: false };
146
+ }
147
+
148
+ // Everything needed to decide whether a worktree can be removed WITHOUT losing work. Two hazards
149
+ // live in git itself, and each is reported BY NAME so the UI can say why rather than just greying
150
+ // out a button:
151
+ // dirty — uncommitted or untracked files sitting in the tree
152
+ // unique commits — commits on this branch that no other branch, remote or tag holds, so removing
153
+ // the tree is the last thing standing between them and `git gc`
154
+ // Anything unreadable counts as unsafe: this gates a delete, so "I could not check" must never
155
+ // read as "fine". (The caller adds the two hazards git cannot see: live sessions in the tree, and
156
+ // "this is the repo's main worktree".)
157
+ function worktreeSafety(dir, branch) {
158
+ const reasons = [];
159
+ const st = git(dir, ['status', '--porcelain']);
160
+ const dirty = st.failed || st.code !== 0 ? true : st.out.length > 0;
161
+ if (dirty) reasons.push(st.code === 0 ? 'uncommitted changes' : 'its state could not be read');
162
+
163
+ let unique = 0;
164
+ if (!branch) {
165
+ unique = -1;
166
+ reasons.push('detached HEAD — no branch names its commits');
167
+ } else {
168
+ // "commits reachable from this branch and from nothing else". --exclude applies to the NEXT
169
+ // ref-listing option only, so --branches drops this branch while --remotes and --tags still
170
+ // count: a copy on a remote means the commits survive the removal.
171
+ const rl = git(dir, ['rev-list', '--count', 'refs/heads/' + branch,
172
+ '--not', '--exclude=' + branch, '--branches', '--remotes', '--tags']);
173
+ unique = rl.code === 0 && /^\d+$/.test(rl.out) ? parseInt(rl.out, 10) : -1;
174
+ if (unique > 0) reasons.push(`${unique} commit${unique === 1 ? '' : 's'} not on any other branch or remote`);
175
+ if (unique < 0) reasons.push('could not verify its commits exist elsewhere');
176
+ }
177
+ return { removable: reasons.length === 0, reasons, dirty, unique };
178
+ }
179
+
180
+ // Remove a linked worktree, refusing anything that would lose work. `dir` arrives from a client, so
181
+ // it is never passed through as given: it must match one of THIS repo's linked worktrees (git's own
182
+ // list is the allowlist), and the main worktree is unreachable by construction. The BRANCH is left
183
+ // alone — a branch is a cheap pointer, and deleting one is a different decision than reclaiming a
184
+ // directory.
185
+ function removeWorktree(root, dir) {
186
+ const top = repoRoot(root);
187
+ if (!top) return { ok: false, error: 'not a git repository' };
188
+ const trees = worktreesAt(top);
189
+ // index 0 is always the main worktree — sliced off, so it can never be the target
190
+ const target = trees.slice(1).find((w) => path.resolve(w.dir) === path.resolve(String(dir || '')));
191
+ if (!target) return { ok: false, error: 'not a linked worktree of this repository' };
192
+
193
+ // Gone from disk already (the PM deleted it by hand): there is nothing to protect, just stale
194
+ // bookkeeping to clear.
195
+ if (!isDir(target.dir)) {
196
+ const p = git(top, ['worktree', 'prune']);
197
+ if (p.code !== 0) return { ok: false, error: (p.err || 'prune failed').split('\n')[0].slice(0, 200) };
198
+ return { ok: true, dir: target.dir, pruned: true };
199
+ }
200
+
201
+ const safety = worktreeSafety(target.dir, target.branch);
202
+ if (!safety.removable) return { ok: false, error: 'kept — ' + safety.reasons.join('; '), safety };
203
+ const r = git(top, ['worktree', 'remove', target.dir]);
204
+ if (r.failed) return { ok: false, error: 'git not available' };
205
+ if (r.code !== 0) return { ok: false, error: (r.err || r.out || 'worktree remove failed').split('\n').filter(Boolean).pop().slice(0, 200) };
206
+ return { ok: true, dir: target.dir, branch: target.branch, pruned: false };
207
+ }
208
+
209
+ // { ok, current, branches[], detached, dirty, repoRoot, subdir, linked, worktrees[] }. A non-repo
210
+ // or a failed git returns ok:false with a reason — NEVER a fabricated branch (a wrong branch chip
211
+ // is worse than none; anti-drift). `subdir` is the workspace's path INSIDE the repo ('' at the
212
+ // top): non-empty means a checkout here moves a tree larger than this workspace, which the UI says
213
+ // out loud.
214
+ function branchInfo(root) {
215
+ // `code` lets a caller tell the two failures apart without matching on prose: `no-git` is a
216
+ // perfectly normal folder (a tenant product repo with no git at all) and deserves no alarm;
217
+ // `git-error` is something the PM may want to look at.
218
+ const top = repoRoot(root);
219
+ if (!top) {
220
+ if (!isRepo(root)) return { ok: false, code: 'no-git', error: 'not a git repository' };
221
+ return { ok: false, code: 'git-error', error: 'git not available' };
222
+ }
223
+ const head = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
224
+ if (head.failed) return { ok: false, code: 'git-error', error: 'git not available' };
225
+ if (head.code !== 0) return { ok: false, code: 'git-error', error: head.err || 'could not read HEAD' };
226
+ const detached = head.out === 'HEAD';
227
+ const current = detached ? null : head.out;
228
+ const refs = git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']);
229
+ const branches = refs.code === 0 && refs.out ? refs.out.split('\n').filter(Boolean) : [];
230
+ const status = git(root, ['status', '--porcelain']);
231
+ const dirty = status.code === 0 ? status.out.length > 0 : false;
232
+ const rel = path.relative(top, path.resolve(root));
233
+ const trees = worktreesAt(top);
234
+ const linked = path.resolve(top) !== path.resolve(trees[0] ? trees[0].dir : top);
235
+ return {
236
+ ok: true, current, branches, detached, dirty,
237
+ repoRoot: top, subdir: rel === '' || rel === '.' ? '' : rel, linked,
238
+ worktrees: trees.map((w) => ({ dir: w.dir, branch: w.branch })),
239
+ };
240
+ }
241
+
242
+ module.exports = { isRepo, repoRoot, validBranchName, branchInfo, checkout, worktrees, worktreePath,
243
+ addWorktree, worktreeSafety, removeWorktree };
@@ -0,0 +1,139 @@
1
+ /* session-state — the presented state of a session, joined from the workspace's TWO sources.
2
+ * (workspace-waiting-on-you R2/R3/R4/R5/R6.)
3
+ *
4
+ * One source of truth for "what word goes beside this session", used on BOTH sides:
5
+ * - server.js requires it (sessionList publishes the joined state)
6
+ * - the browser loads it as /lib/session-state.js before app.js (window.PMOSSessionState)
7
+ * so the sidebar and the server can never hold different opinions about one session. That is not
8
+ * hypothetical tidiness: QUIET_AFTER_MS was once re-typed as a literal in app.js, two copies of one
9
+ * number free to drift, and the fix was to ship it from one place. A duplicated TABLE fails the
10
+ * same way with more surface area.
11
+ *
12
+ * ── THE TWO SOURCES, AND WHY NEITHER IS ENOUGH ALONE ────────────────────────────────────────
13
+ * CADENCE (harness-parse.deriveState ← lastOutputAt) answers "is anything happening?"
14
+ * STRUCTURE (transcript-read.turn ← the JSONL) answers "whose turn is it?"
15
+ *
16
+ * Cadence alone cannot tell a finished turn from a crash from a slow tool — which is why
17
+ * workspace-harness-tower's R1 forbade upgrading `quiet` to "waiting on you", and why that rule is
18
+ * SATISFIED here rather than repealed: the stronger words are built from BOTH, and every cell
19
+ * without corroboration still says exactly what it said before.
20
+ *
21
+ * The two contributions are NOT symmetric, and pretending otherwise would be the first step toward
22
+ * over-claiming:
23
+ * - for `needs` the transcript is load-bearing and the quiet clock is only a SETTLE-TIMER
24
+ * (the terminal is still painting the final block for a moment after a turn ends)
25
+ * - for `blocked` the transcript only says a call is open; the quiet clock does the real
26
+ * discriminating, because a running tool emits bytes and a permission prompt does not
27
+ *
28
+ * ── THE PROPERTY THIS FILE EXISTS TO GUARANTEE (R4) ─────────────────────────────────────────
29
+ * `needs` is reachable ONLY from turn === 'user', and transcript-read sets 'user' only when NO
30
+ * tool_use is unresolved. A crashed session's dangling call is a permanent phantom (2/25 real
31
+ * transcripts) and a phantom IS an unresolved call — so a phantom can only ever reach `tool`, and
32
+ * `needs` is UNCONSTRUCTABLE for it.
33
+ *
34
+ * There is deliberately NO `if (!phantom)` anywhere here. A check is a thing a later change can
35
+ * forget to call; a state that cannot be built needs no discipline to stay honest. This is the same
36
+ * discipline as the shipped "age without liveness is refused" — and if you are editing this file,
37
+ * the test that matters is `needs you` over the join's WHOLE domain, not one example of it.
38
+ */
39
+ 'use strict';
40
+ (function (root, factory) {
41
+ const api = factory();
42
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
43
+ else root.PMOSSessionState = api;
44
+ })(typeof self !== 'undefined' ? self : this, function () {
45
+
46
+ // The cadence clock's three values (harness-parse.deriveState) — this module's first input.
47
+ const CADENCE = ['working', 'quiet', 'exited'];
48
+ // The transcript's answer to whose turn it is — this module's second input. `null` = no answer,
49
+ // which is the NORMAL case for a shell/codex session and for any claude session whose transcript
50
+ // cannot be resolved. It is not an error and draws no apology.
51
+ const TURN = ['user', 'agent', 'tool', null];
52
+ // What can end up on screen. The first three are pre-existing and unchanged; the last two are
53
+ // this feature. `failed` is absent by design — it has no observable (harness-tower).
54
+ const PRESENTED = ['working', 'quiet', 'exited', 'needs', 'blocked'];
55
+
56
+ // The word beside the glyph. Deliberately separate from the key: `blocked`'s word names its own
57
+ // uncertainty, because its evidence cannot distinguish a permission prompt from a hung tool from
58
+ // a corpse. The surface may not launder that into confidence.
59
+ const WORD = {
60
+ working: 'working',
61
+ quiet: 'quiet',
62
+ exited: 'exited',
63
+ needs: 'needs you',
64
+ blocked: 'maybe blocked',
65
+ };
66
+
67
+ // Filled = corroborated by both sources, hollow = suspected from one. `needs` takes ◆, which has
68
+ // sat declared-but-dead in the client's GLYPH map since the harness tower; this makes it live.
69
+ const GLYPH = { working: '●', quiet: '◼', exited: '○', needs: '◆', blocked: '◇' };
70
+
71
+ // Which presented states may carry the open call's verb + age. Unchanged for `working` (shipped
72
+ // behaviour); extended to `blocked`, where the age is the most informative part of a weak claim —
73
+ // "a call has been open 6m and nothing is moving" is the whole signal.
74
+ const CARRIES_CALL_AGE = { working: true, blocked: true };
75
+
76
+ /**
77
+ * THE JOIN (R2). Total over CADENCE × TURN: every one of the twelve cells is defined here, and
78
+ * `presentedStates()` below exists so a test can walk the domain rather than sample it.
79
+ *
80
+ * Pure: no clock read, no I/O, no DOM. The caller supplies the already-derived cadence state, so
81
+ * this function cannot disagree with `deriveState` about the threshold — it never sees one.
82
+ *
83
+ * turn ↓ / cadence → working quiet exited
84
+ * 'user' working NEEDS exited
85
+ * 'tool' working BLOCKED exited
86
+ * 'agent' working quiet exited
87
+ * null working quiet exited
88
+ *
89
+ * Three cells change from pre-feature behaviour. The other nine are byte-identical, and that is
90
+ * asserted rather than assumed.
91
+ */
92
+ function presented(cadence, turn) {
93
+ // `exited` is the server's own lifecycle fact and outranks anything the transcript says: a
94
+ // transcript is a file that outlives its process, so a dead session's last record would
95
+ // otherwise keep claiming a turn forever.
96
+ if (cadence === 'exited') return 'exited';
97
+ // Bytes are arriving. Whatever the transcript says, something IS happening, so no stronger
98
+ // claim is available or needed — and this is what stops `needs` firing during the moment the
99
+ // terminal is still painting the turn it just finished.
100
+ if (cadence === 'working') return 'working';
101
+ if (cadence !== 'quiet') return 'working'; // unknown cadence ⇒ the weakest live word, never a claim
102
+ // Quiet. Only here can the transcript raise the claim above `quiet`.
103
+ if (turn === 'user') return 'needs'; // turn ended AND nothing moving ⇒ the human is up
104
+ if (turn === 'tool') return 'blocked'; // a call is open AND nothing moving ⇒ suspect
105
+ return 'quiet'; // 'agent' (model call in flight) or null (no source)
106
+ }
107
+
108
+ /** Every (cadence, turn, presented) triple. The domain is small — walk it, don't sample it. */
109
+ function presentedStates() {
110
+ const out = [];
111
+ for (const cadence of CADENCE) {
112
+ for (const turn of TURN) out.push({ cadence, turn, presented: presented(cadence, turn) });
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** Does this presented state make the strong "this session wants you" claim? */
118
+ function isNeedsClaim(p) { return p === 'needs'; }
119
+
120
+ /**
121
+ * The full label for a row or tab: the word, plus the quiet clock's age on `quiet`, plus the open
122
+ * call's verb and age where the state carries it. `fmtDur` is injected (the client and the server
123
+ * format durations for different audiences) and the ages are computed by the CALLER, because this
124
+ * module reads no clock.
125
+ */
126
+ function label(p, { quietMs = null, call = null, fmtDur = null } = {}) {
127
+ const w = WORD[p] || p;
128
+ const d = (ms) => (fmtDur && ms != null ? fmtDur(ms) : null);
129
+ if (p === 'quiet') { const s = d(quietMs); return s ? `${w} ${s}` : w; }
130
+ if (CARRIES_CALL_AGE[p] && call && call.verb) {
131
+ const s = d(call.ageMs);
132
+ return s ? `${w} · ${call.verb} ${s}` : `${w} · ${call.verb}`;
133
+ }
134
+ return w;
135
+ }
136
+
137
+ return { CADENCE, TURN, PRESENTED, WORD, GLYPH, CARRIES_CALL_AGE,
138
+ presented, presentedStates, isNeedsClaim, label };
139
+ });