create-pmos 0.7.3 → 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 +68 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/skills/pmos-router.skill +22 -0
- package/payload/skills-native/pmos-router/SKILL.md +22 -0
- package/workspace/README.md +28 -0
- package/workspace/lib/apply-change.js +51 -1
- package/workspace/lib/console-state.js +56 -1
- package/workspace/lib/git-info.js +197 -25
- package/workspace/lib/session-state.js +139 -0
- package/workspace/lib/transcript-read.js +28 -6
- package/workspace/public/app.js +429 -274
- package/workspace/public/index.html +30 -7
- package/workspace/public/styles.css +898 -624
- package/workspace/public/vendor/ibm-plex-mono-400.woff2 +0 -0
- package/workspace/public/vendor/ibm-plex-mono-700.woff2 +0 -0
- package/workspace/public/vendor/space-grotesk-var.woff2 +0 -0
- package/workspace/server.js +401 -234
- package/workspace/lib/correction-record.js +0 -127
- package/workspace/lib/finding-contract.js +0 -162
- package/workspace/skills/doc-review.skill +0 -119
package/README.md
CHANGED
|
@@ -333,6 +333,74 @@ 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
|
+
|
|
360
|
+
## What's new in 0.8.0
|
|
361
|
+
|
|
362
|
+
**The document review is gone. There is a document editor instead.**
|
|
363
|
+
|
|
364
|
+
Minor, not patch: a feature that shipped in 0.7.0–0.7.3 has been **removed**, and what replaces it is
|
|
365
|
+
a different thing with a different point. If you were using the review surface, it is not there any
|
|
366
|
+
more — read on.
|
|
367
|
+
|
|
368
|
+
### Why it went
|
|
369
|
+
|
|
370
|
+
It answered a question nobody asked. An agent read your planning documents and told you what it
|
|
371
|
+
thought was wrong with them, before you had touched anything. What was actually wanted was the
|
|
372
|
+
opposite direction: **you** edit a document, and the tooling reacts to *your* change.
|
|
373
|
+
|
|
374
|
+
**It also never fully worked.** The browser's Edit → Apply path depended on a script the server was
|
|
375
|
+
never configured to serve, so it 404'd silently in every version that shipped it. The tests that
|
|
376
|
+
covered it went straight to the endpoint and never exercised the path a person takes. If you tried to
|
|
377
|
+
edit a proposal and apply it and nothing happened, that was why, and it was our bug.
|
|
378
|
+
|
|
379
|
+
### What replaces it
|
|
380
|
+
|
|
381
|
+
- **Edit any markdown document in the reader.** `edit` in the document header; `save`, `cancel` and
|
|
382
|
+
`history` while you are in it. ⌘S saves.
|
|
383
|
+
- **The diff is beside you as you type** — the replaced lines struck through, the new ones marked,
|
|
384
|
+
with a running `+n −n` and the line the change starts at. Nothing to summon; it appears when the
|
|
385
|
+
document differs from what you opened.
|
|
386
|
+
- **A save that refuses rather than clobbers.** If the file changed on disk since you opened it, the
|
|
387
|
+
save fails and says so, and **your text stays in the editor** so you can copy it out. It will not
|
|
388
|
+
merge over whoever changed it.
|
|
389
|
+
- **Every save is logged, per repository.** What changed, where, and — if you write one — why. It
|
|
390
|
+
lands in your repo's own state directory, never in the installed app. `history` in the document
|
|
391
|
+
header shows that document's own log.
|
|
392
|
+
|
|
393
|
+
The log records the changed region and your reason, **not a parallel history of your repository**.
|
|
394
|
+
`git log` remains the record of what your files contain; this records what you changed and why, which
|
|
395
|
+
git has no place to put.
|
|
396
|
+
|
|
397
|
+
### Not built yet, and named so you are not waiting for it
|
|
398
|
+
|
|
399
|
+
Two halves of the intended feature are **not** in this release: using a save to work out which *other*
|
|
400
|
+
documents are now out of step and drafting the edits that would realign them, and feeding the
|
|
401
|
+
accumulated change log to agents as context. The editor, the diff and the log are here; the
|
|
402
|
+
propagation is not.
|
|
403
|
+
|
|
336
404
|
## What's new in 0.7.3
|
|
337
405
|
|
|
338
406
|
**A fix — read this if you ran a document review on 0.7.1 or 0.7.2, and especially if you have more
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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
|
package/workspace/README.md
CHANGED
|
@@ -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.
|
|
@@ -102,5 +102,55 @@
|
|
|
102
102
|
return { ok: true, text: r.text, eol: r.eol };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Save a whole edited document (workspace-doc-editor R3).
|
|
107
|
+
* current — the file on disk right now
|
|
108
|
+
* base — the text the editor loaded, i.e. what the PM's edit was made against
|
|
109
|
+
* next — what they wrote
|
|
110
|
+
* Refuses when `current` no longer matches `base`: the document moved under the PM, and writing
|
|
111
|
+
* `next` would silently discard whoever changed it. Same stance as applyChange's stale check —
|
|
112
|
+
* refuse loudly rather than merge — but for the case where the whole document is the unit.
|
|
113
|
+
*/
|
|
114
|
+
function saveWhole(current, base, next) {
|
|
115
|
+
if (typeof current !== 'string' || typeof base !== 'string' || typeof next !== 'string') {
|
|
116
|
+
return { ok: false, reason: 'not-text' };
|
|
117
|
+
}
|
|
118
|
+
if (toLf(current) !== toLf(base)) {
|
|
119
|
+
return { ok: false, reason: 'stale', detail: 'this document changed on disk since you opened it' };
|
|
120
|
+
}
|
|
121
|
+
if (toLf(current) === toLf(next)) return { ok: false, reason: 'no-change' };
|
|
122
|
+
const eol = detectEol(current);
|
|
123
|
+
const out = toLf(next);
|
|
124
|
+
return { ok: true, text: eol === '\n' ? out : out.replace(/\n/g, eol), eol };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The changed REGION of an edit, for the change log (R4/R8).
|
|
129
|
+
*
|
|
130
|
+
* Common leading and trailing lines are trimmed away, leaving the part that actually differs. That
|
|
131
|
+
* is deliberately not a stored diff history — git holds the repository's content, and R4 says so.
|
|
132
|
+
* What this exists for is R8: an agent reading the log should see *what the PM tends to change and
|
|
133
|
+
* how*, which line counts alone cannot convey and whole documents would bury.
|
|
134
|
+
*/
|
|
135
|
+
function changedRegion(base, next, maxLines = 40) {
|
|
136
|
+
const a = toLf(base == null ? '' : base).split('\n');
|
|
137
|
+
const b = toLf(next == null ? '' : next).split('\n');
|
|
138
|
+
let s = 0;
|
|
139
|
+
while (s < a.length && s < b.length && a[s] === b[s]) s++;
|
|
140
|
+
let e = 0;
|
|
141
|
+
while (e < a.length - s && e < b.length - s && a[a.length - 1 - e] === b[b.length - 1 - e]) e++;
|
|
142
|
+
const before = a.slice(s, a.length - e);
|
|
143
|
+
const after = b.slice(s, b.length - e);
|
|
144
|
+
const clip = (arr) => (arr.length > maxLines
|
|
145
|
+
? arr.slice(0, maxLines).concat([`… ${arr.length - maxLines} more line(s)`]) : arr);
|
|
146
|
+
return {
|
|
147
|
+
at_line: s + 1,
|
|
148
|
+
removed: before.length,
|
|
149
|
+
added: after.length,
|
|
150
|
+
before: clip(before).join('\n'),
|
|
151
|
+
after: clip(after).join('\n'),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { applyChange, changeStat, matchesRendered, detectEol, saveWhole, changedRegion };
|
|
106
156
|
});
|
|
@@ -114,4 +114,59 @@ function radar({ stateDir, snapshotDir }) {
|
|
|
114
114
|
} };
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
|
|
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.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
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
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
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
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
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
|
-
|
|
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 };
|