create-pmos 0.16.1 → 0.18.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 +60 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/README.md +25 -0
- package/workspace/lib/engine/workflow-state.js +129 -22
- package/workspace/lib/tree-share.js +129 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +3 -3
- package/workspace/server.js +391 -49
package/README.md
CHANGED
|
@@ -333,6 +333,66 @@ 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.0
|
|
337
|
+
|
|
338
|
+
**Several sessions in one folder now say so.** Run `npx create-pmos@latest workspace`; the version
|
|
339
|
+
bump means an existing install auto-upgrades.
|
|
340
|
+
|
|
341
|
+
- **Starting a session where others are already running tells you what you are sharing.** Sessions
|
|
342
|
+
in one checkout share one branch, one index and one set of files — that was true before and
|
|
343
|
+
nothing said it. The new session's pane now names the sessions already there and points at the
|
|
344
|
+
alternative (a new branch in its own folder). It appears on the ordinary "+ session" path too,
|
|
345
|
+
where no git operation runs at all: the server had already worked out that other sessions would
|
|
346
|
+
be disturbed, and was throwing the answer away unless you happened to pick a branch.
|
|
347
|
+
- **It counts the right thing.** A repository registered at two paths — the project root and a
|
|
348
|
+
subdirectory of it, the ordinary shape when you add a product repo by path — is **one** checkout,
|
|
349
|
+
and its sessions are now told about each other; before, each was told it was alone. Two
|
|
350
|
+
*worktrees* of one repository are **two** checkouts and are deliberately not warned about, because
|
|
351
|
+
those are the sessions that isolation already made safe. A folder with no git says nothing, since
|
|
352
|
+
there is no index to collide over.
|
|
353
|
+
- **One session is the declared writer; the rest read.** The first live session in a checkout holds
|
|
354
|
+
the claim, and you can hand it to another (`POST /api/tree/claim`); `GET /api/tree/peers` reports
|
|
355
|
+
who holds it and who else is there. It moves only when you move it — never on a timer or an exit —
|
|
356
|
+
and when its holder exits the checkout reads *vacant* rather than promoting somebody at random.
|
|
357
|
+
- **Be clear about what that is: a declaration, not a lock.** The workspace hosts your agent's
|
|
358
|
+
terminal; it does not sit between the agent and the files, so nothing here prevents a write, a
|
|
359
|
+
checkout or a commit, and no session is ever refused because another one is there. It makes a
|
|
360
|
+
shared checkout visible. The discipline stays yours.
|
|
361
|
+
- **A folder you are working in can no longer be reclaimed out from under you.** Removing a
|
|
362
|
+
worktree already refused when sessions were running in it — but it counted only sessions in the
|
|
363
|
+
workspace registered at that exact folder, so a session running in a subdirectory of it was
|
|
364
|
+
invisible. It is counted now, and named in the refusal.
|
|
365
|
+
|
|
366
|
+
## What's new in 0.17.0
|
|
367
|
+
|
|
368
|
+
**Your board stops calling shipped work unstarted.** Run `npx create-pmos@latest workspace`; the
|
|
369
|
+
version bump means an existing install auto-upgrades.
|
|
370
|
+
|
|
371
|
+
- **An initiative's status is now read from what happened, not from what someone typed.** The
|
|
372
|
+
workspace took each initiative's state from a `status:` line written by hand into a PRD and
|
|
373
|
+
matched it against a list of words. In the repo this was built in, that one field held 23
|
|
374
|
+
different values, 33 initiatives had none at all, and several were cut off mid-sentence by a
|
|
375
|
+
stray comma. The plainest value of the lot — `build complete, PR #138` — matched the word
|
|
376
|
+
"build" first and displayed as *in progress*. Status is now derived from your acceptance
|
|
377
|
+
records, your logged runs, your open items, and your repository's own merge history, and each
|
|
378
|
+
row states which of those it was read from. Nothing you type can move it.
|
|
379
|
+
- **`delivered` and `accepted` are no longer the same thing.** Work that merged and work you
|
|
380
|
+
actually signed off are now separate states. That gap is usually the interesting number: in the
|
|
381
|
+
repo this was built in, 62 of 63 delivered initiatives had no acceptance recorded — invisible
|
|
382
|
+
while both wore one label.
|
|
383
|
+
- **The board reads your whole project history, not just the branch you are standing on.** It
|
|
384
|
+
shared a commit range with the CI gate, and that range is *empty* when your branch is level with
|
|
385
|
+
`main` — so every initiative reported "no implementation commits" and fell back to its earliest
|
|
386
|
+
stage. Work finished weeks ago looked identical to work never begun. The gate still reads only
|
|
387
|
+
the pull request, which is correct for judging whether a spec came before its build.
|
|
388
|
+
- **A history it cannot read is now said out loud.** In a shallow clone (some CI checkouts) the
|
|
389
|
+
delivery history genuinely is not there. The board reports that, with the reason, instead of
|
|
390
|
+
quietly showing everything as unstarted — which would look exactly like a project where nothing
|
|
391
|
+
has happened.
|
|
392
|
+
|
|
393
|
+
Unchanged where it matters: **no database, no network at runtime, no telemetry.** The server still
|
|
394
|
+
binds `127.0.0.1` only. The file-mode kit itself is unchanged and still dependency-free.
|
|
395
|
+
|
|
336
396
|
## What's new in 0.16.1
|
|
337
397
|
|
|
338
398
|
**Two fixes on the Terminal Workspace's entry path — how a repository reaches a running workspace.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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
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.
|
|
@@ -164,25 +164,88 @@ function git(root, args) {
|
|
|
164
164
|
* evaluated has not been satisfied, and saying so is the whole point of the honesty rule above.
|
|
165
165
|
*/
|
|
166
166
|
function commitRange(root) {
|
|
167
|
+
return buildRange(root, { spec: null });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/*
|
|
171
|
+
* The BOARD's range: the whole trunk, not the pull request.
|
|
172
|
+
*
|
|
173
|
+
* These are two different questions and they were sharing an answer. The gate asks "inside this
|
|
174
|
+
* diff, did the spec precede the build?" — only decidable within the PR. The board asks "where does
|
|
175
|
+
* this initiative stand?", which the PR range cannot answer at all: on a branch level with `main`
|
|
176
|
+
* the range is EMPTY, so every initiative reported `no implementation commits in this range` and
|
|
177
|
+
* collapsed to stage `spec`. A delivered initiative rendered identically to one never started.
|
|
178
|
+
*
|
|
179
|
+
* Splitting them means a rule can read `done` on the board and still be evaluated inside the gate.
|
|
180
|
+
* That is intended and is stated in the board's own footer, so it never reads as a discrepancy.
|
|
181
|
+
*/
|
|
182
|
+
function historyRange(root) {
|
|
183
|
+
const trunk = ['origin/main', 'main', 'origin/master', 'master']
|
|
184
|
+
.find((c) => git(root, ['rev-parse', '--verify', '--quiet', c]));
|
|
185
|
+
return buildRange(root, { spec: trunk || 'HEAD', whole: true });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/*
|
|
189
|
+
* ONE pass over the range, not one git call per question.
|
|
190
|
+
*
|
|
191
|
+
* Two `git log` invocations build every index the ladder needs: which paths each commit CHANGED
|
|
192
|
+
* (for buildCommits) and where each path was ADDED (for firstAppearance). Previously firstAppearance
|
|
193
|
+
* shelled out once per path per initiative and buildCommits ran `git show` once per commit per
|
|
194
|
+
* initiative — 330+ processes for the board on an empty range (23 s), and O(commits × initiatives)
|
|
195
|
+
* on a full one, which is what made full history look impossible rather than merely unimplemented.
|
|
196
|
+
*/
|
|
197
|
+
function buildRange(root, { spec, whole }) {
|
|
167
198
|
if ((git(root, ['rev-parse', '--is-shallow-repository']) || '').trim() === 'true') {
|
|
168
199
|
return { ok: false, reason: 'shallow clone — no history to order commits by (CI needs fetch-depth: 0)' };
|
|
169
200
|
}
|
|
170
|
-
const candidates = [];
|
|
171
|
-
if (process.env.GITHUB_BASE_REF) candidates.push(`origin/${process.env.GITHUB_BASE_REF}`);
|
|
172
|
-
candidates.push('origin/main', 'main', 'origin/master', 'master');
|
|
173
201
|
let base = null;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
if (
|
|
202
|
+
let range = spec;
|
|
203
|
+
if (!range) {
|
|
204
|
+
const candidates = [];
|
|
205
|
+
if (process.env.GITHUB_BASE_REF) candidates.push(`origin/${process.env.GITHUB_BASE_REF}`);
|
|
206
|
+
candidates.push('origin/main', 'main', 'origin/master', 'master');
|
|
207
|
+
for (const c of candidates) {
|
|
208
|
+
if (!git(root, ['rev-parse', '--verify', '--quiet', c])) continue;
|
|
209
|
+
const mb = git(root, ['merge-base', c, 'HEAD']);
|
|
210
|
+
if (mb && mb.trim()) { base = mb.trim(); break; }
|
|
211
|
+
}
|
|
212
|
+
range = base ? `${base}..HEAD` : 'HEAD';
|
|
178
213
|
}
|
|
179
|
-
const
|
|
180
|
-
const log = git(root, ['log', '--reverse', '--format=%H', spec]);
|
|
214
|
+
const log = git(root, ['log', '--reverse', '--format=%x1e%H %ct', '--name-only', range]);
|
|
181
215
|
if (log == null) return { ok: false, reason: 'git history unreadable' };
|
|
182
|
-
|
|
216
|
+
|
|
217
|
+
const shas = [];
|
|
218
|
+
const changed = []; // index -> string[] of paths touched
|
|
219
|
+
const dates = []; // index -> commit unix seconds (what `legacy` is decided by)
|
|
220
|
+
for (const chunk of log.split('\x1e')) {
|
|
221
|
+
if (!chunk.trim()) continue;
|
|
222
|
+
const lines = chunk.split('\n');
|
|
223
|
+
const head = (lines.shift() || '').trim();
|
|
224
|
+
const [sha, ct] = head.split(/\s+/);
|
|
225
|
+
if (!sha) continue;
|
|
226
|
+
shas.push(sha);
|
|
227
|
+
dates.push(Number.isFinite(parseInt(ct, 10)) ? parseInt(ct, 10) : null);
|
|
228
|
+
changed.push(lines.map((s) => s.trim()).filter(Boolean));
|
|
229
|
+
}
|
|
183
230
|
const index = new Map();
|
|
184
231
|
shas.forEach((sha, i) => index.set(sha, i));
|
|
185
|
-
|
|
232
|
+
|
|
233
|
+
// Where each path was ADDED, as an index into the same range. `--diff-filter=A` in one pass.
|
|
234
|
+
const added = new Map();
|
|
235
|
+
const addLog = git(root, ['log', '--reverse', '--format=%x1e%H', '--diff-filter=A', '--name-only', range]);
|
|
236
|
+
if (addLog != null) {
|
|
237
|
+
for (const chunk of addLog.split('\x1e')) {
|
|
238
|
+
if (!chunk.trim()) continue;
|
|
239
|
+
const lines = chunk.split('\n');
|
|
240
|
+
const sha = (lines.shift() || '').trim();
|
|
241
|
+
const at = index.has(sha) ? index.get(sha) : null;
|
|
242
|
+
if (at == null) continue;
|
|
243
|
+
for (const f of lines.map((s) => s.trim()).filter(Boolean)) {
|
|
244
|
+
if (!added.has(f)) added.set(f, at);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { ok: true, base, index, shas, changed, dates, added, wholeHistory: whole ? true : !base };
|
|
186
249
|
}
|
|
187
250
|
|
|
188
251
|
/*
|
|
@@ -200,6 +263,10 @@ function commitRange(root) {
|
|
|
200
263
|
function firstAppearance(root, range, rel) {
|
|
201
264
|
const onDisk = fs.existsSync(path.join(root, rel));
|
|
202
265
|
if (!range.ok) return onDisk ? undefined : null;
|
|
266
|
+
// The single-pass index answers first, and over the whole trunk it answers everything that has
|
|
267
|
+
// ever been committed — so the fallback below only runs for a PR range, where "added before the
|
|
268
|
+
// range" and "never committed" are genuinely different answers and only git can tell them apart.
|
|
269
|
+
if (range.added && range.added.has(rel)) return range.added.get(rel);
|
|
203
270
|
const log = git(root, ['log', '--diff-filter=A', '--reverse', '--format=%H', '--', rel]);
|
|
204
271
|
const sha = log ? log.split('\n').map((s) => s.trim()).filter(Boolean)[0] : null;
|
|
205
272
|
if (!sha) return onDisk ? Infinity : null;
|
|
@@ -213,9 +280,10 @@ function buildCommits(root, range, lane) {
|
|
|
213
280
|
if (!range.ok) return { unknown: true };
|
|
214
281
|
let first = null; let count = 0; let sample = null;
|
|
215
282
|
for (let i = 0; i < range.shas.length; i++) {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
283
|
+
// range.changed came from the range's own single pass; the per-commit `git show` this replaced
|
|
284
|
+
// ran once per commit PER INITIATIVE, which is why the board took 23 s before it read anything.
|
|
285
|
+
const files = (range.changed && range.changed[i]) || [];
|
|
286
|
+
const hit = files.find((f) => isBuildPath(f, lane));
|
|
219
287
|
if (!hit) continue;
|
|
220
288
|
count++;
|
|
221
289
|
if (first === null) { first = i; sample = hit; }
|
|
@@ -290,10 +358,29 @@ function computeWorkflow(id, opts) {
|
|
|
290
358
|
const effLane = knownLane ? lane : 'feature'; // unknown lane = strictest, as the bash gate does
|
|
291
359
|
const range = (opts && opts.range) || commitRange(root);
|
|
292
360
|
// See ENFORCED_FROM: git decides, the frontmatter only breaks a tie git cannot see.
|
|
361
|
+
//
|
|
362
|
+
// `recIdx === -1` — "the record predates this range" — is the right test for a PR range and a
|
|
363
|
+
// MEANINGLESS one over the whole trunk, where nothing predates the range and every legacy record
|
|
364
|
+
// would suddenly be enforced. So the commit DATE decides whenever the range can supply it, and
|
|
365
|
+
// -1 keeps its meaning for the gate's range. Same question, asked of evidence that survives both.
|
|
293
366
|
const recIdx = firstAppearance(root, range, recRel);
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
367
|
+
const recAt = (range.dates && typeof recIdx === 'number' && recIdx >= 0) ? range.dates[recIdx] : null;
|
|
368
|
+
/*
|
|
369
|
+
* `reconstructed_from` — the merge date of work whose record was written afterwards.
|
|
370
|
+
*
|
|
371
|
+
* A record backfilled today for a PR that merged in June has TODAY's commit date, so the date
|
|
372
|
+
* test would enforce today's rules on work that predates them and report a violation nobody
|
|
373
|
+
* could ever have avoided. The declared merge date decides instead.
|
|
374
|
+
*
|
|
375
|
+
* This is not an escape hatch: it must name a date, that date is checkable against the merge it
|
|
376
|
+
* claims, and declaring one AFTER `ENFORCED_FROM` makes the record non-legacy — so work that
|
|
377
|
+
* genuinely skipped the loop still reports blocked, which is the point of writing it down.
|
|
378
|
+
*/
|
|
379
|
+
const recon = String(fm.reconstructed_from || '').trim().slice(0, 10);
|
|
380
|
+
const legacy = recIdx === -1 ? true
|
|
381
|
+
: /^\d{4}-\d{2}-\d{2}$/.test(recon) ? recon < ENFORCED_FROM
|
|
382
|
+
: recAt ? new Date(recAt * 1000).toISOString().slice(0, 10) < ENFORCED_FROM
|
|
383
|
+
: String(fm.timestamp || '').slice(0, 10) < ENFORCED_FROM; // fallback — see ENFORCED_FROM
|
|
297
384
|
/*
|
|
298
385
|
* `legacy` answers the ENFORCEMENT question — should this PR fail? It is NOT the visibility
|
|
299
386
|
* question. Filtering the board by it was a real bug: on `main`, every record is legacy, so the
|
|
@@ -304,7 +391,12 @@ function computeWorkflow(id, opts) {
|
|
|
304
391
|
* `lastActivity` is the honest fallback for everything else, because "when was this last worked
|
|
305
392
|
* on" is a fact the repo already holds and nobody has to maintain.
|
|
306
393
|
*/
|
|
307
|
-
|
|
394
|
+
// `● in this change` must keep meaning "the diff under review touches this". Over the whole trunk
|
|
395
|
+
// that is true of everything, which makes the marker noise — so the board passes the GATE's range
|
|
396
|
+
// separately and the marker is answered from that, or dropped when there is no such range.
|
|
397
|
+
const changeRange = (opts && opts.changeRange) || (range.wholeHistory ? null : range);
|
|
398
|
+
const chgIdx = changeRange ? firstAppearance(root, changeRange, recRel) : null;
|
|
399
|
+
const inChange = !!changeRange && chgIdx !== -1 && chgIdx !== null && chgIdx !== undefined;
|
|
308
400
|
const lastActivity = (() => {
|
|
309
401
|
const paths = [recRel, fm.prd_path, fm.rubric_path].filter(Boolean);
|
|
310
402
|
const out = git(root, ['log', '-1', '--format=%ct', '--', ...paths]);
|
|
@@ -522,10 +614,14 @@ function computeWorkflow(id, opts) {
|
|
|
522
614
|
steps.push(step('spec', 'spec', 'done', `${specNames} — first-appeared at or before the first build commit`));
|
|
523
615
|
} else {
|
|
524
616
|
const late = rubricIdx !== null && rubricIdx > build.first && prdIdx <= build.first ? rubric : prd;
|
|
525
|
-
|
|
617
|
+
// `legacy` applies here exactly as it does to O4. It always should have — the rule simply
|
|
618
|
+
// never fired for a legacy record while the board shared the gate's PR range, because an
|
|
619
|
+
// empty range has no build commit to be out of order with. Reading the trunk exposed it:
|
|
620
|
+
// 81 records that predate enforcement were suddenly failing a rule written after them.
|
|
621
|
+
steps.push(step('spec', 'spec', legacy ? 'unknown' : 'blocked',
|
|
526
622
|
`ORDER VIOLATION — implementation (${build.sample}) was committed BEFORE ${late} first appeared`,
|
|
527
623
|
'the rubric is the spec (D13). Land the spec artifacts in an earlier commit than the build, or split this PR so the spec lands first',
|
|
528
|
-
{ blocking:
|
|
624
|
+
{ blocking: !legacy, rule: rubricIdx !== null && rubricIdx > build.first && prdIdx <= build.first ? 'O2' : 'O1' }));
|
|
529
625
|
}
|
|
530
626
|
}
|
|
531
627
|
}
|
|
@@ -776,6 +872,10 @@ function renderBoard(rows, opts) {
|
|
|
776
872
|
out.push('');
|
|
777
873
|
// Never a silent cap — a truncated list that does not say so reads as "that is everything".
|
|
778
874
|
if (hidden) out.push(` ${c(33, `+ ${hidden} more without a recorded acceptance, not shown`)} ${c(90, `— node ${opts.self} --all`)}`);
|
|
875
|
+
// The two ranges, said out loud. The board reads the whole trunk so a delivered initiative does
|
|
876
|
+
// not render as unstarted; the gate reads the PR so "spec before build" stays decidable. A rule
|
|
877
|
+
// can therefore read done here and still be evaluated on the PR — intended, and not a discrepancy.
|
|
878
|
+
out.push(` ${c(90, 'derived from the whole trunk history; the CI gate evaluates order within the PR only')}`);
|
|
779
879
|
out.push(` ${c(90, `one initiative in detail: node ${opts.self} <id>`)}`);
|
|
780
880
|
out.push('');
|
|
781
881
|
return out.join('\n');
|
|
@@ -871,8 +971,12 @@ function main(argv) {
|
|
|
871
971
|
return 0;
|
|
872
972
|
}
|
|
873
973
|
|
|
874
|
-
|
|
875
|
-
|
|
974
|
+
// R-7: the BOARD reads the trunk, not the pull request. The gate path above keeps commitRange().
|
|
975
|
+
// The PR range is still computed and passed alongside — it is what `● in this change` means, and
|
|
976
|
+
// it is the one thing the trunk range genuinely cannot answer.
|
|
977
|
+
const range = historyRange(root);
|
|
978
|
+
const changeRange = commitRange(root);
|
|
979
|
+
const rows = listInitiatives(root).map((i) => computeWorkflow(i, { root, range, changeRange }));
|
|
876
980
|
if (json) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return 0; }
|
|
877
981
|
if (!rows.length) { process.stdout.write('\n no initiative records found — is this a PMOS repo?\n\n'); return 1; }
|
|
878
982
|
process.stdout.write(renderBoard(rows, { self, all }));
|
|
@@ -882,6 +986,9 @@ function main(argv) {
|
|
|
882
986
|
module.exports = {
|
|
883
987
|
computeWorkflow, renderLadder, renderBoard, listInitiatives, layout, inFlight, BOARD_CAP,
|
|
884
988
|
parseLeadingFrontmatter, specimenRoute, surfaceRoute, priorsCitation, isBuildPath, commitRange,
|
|
989
|
+
// the board's trunk range, exported so the suite can assert the split: the same record is legacy
|
|
990
|
+
// under BOTH ranges, and `in this change` is false under the trunk one.
|
|
991
|
+
historyRange,
|
|
885
992
|
ENFORCED_FROM,
|
|
886
993
|
};
|
|
887
994
|
|
|
@@ -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.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "pmos-workspace",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.18.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmos-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"private": true,
|
|
5
|
-
"description": "PMOS Terminal Workspace
|
|
5
|
+
"description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
8
8
|
"pmos": "bin/pmos.js"
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"playwright-core": "^1.61.1",
|
|
30
30
|
"ws": "^8.18.0"
|
|
31
31
|
},
|
|
32
|
-
"//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs
|
|
32
|
+
"//devDependencies": "design-parity's pixel axis only. Kept OUT of dependencies deliberately: `create-pmos workspace` installs this package with `npm install -g`, which takes dependencies and not devDependencies, so an adopter installing a local PM tool does not also install a screenshot differ. design-parity.js requires both optionally and degrades to text+style+geometry when they are absent, so the parity check still runs — it just stops measuring pixels, and says so.",
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"pixelmatch": "^7.1.0",
|
|
35
35
|
"pngjs": "^7.0.0"
|
package/workspace/server.js
CHANGED
|
@@ -71,6 +71,7 @@ const consoleState = require('./lib/console-state'); // read-only console surf
|
|
|
71
71
|
const boardCache = require('./lib/board-cache'); // the workflow board, cached + derived off the event loop (R4)
|
|
72
72
|
const fmParse = require('./lib/file-mode-parse'); // canonical file-mode parsers, shared with metrics.py (D64 D)
|
|
73
73
|
const gitInfo = require('./lib/git-info'); // read-mostly branch awareness + select/create at session start
|
|
74
|
+
const treeShare = require('./lib/tree-share'); // who shares a working tree, and who is DECLARED to be driving it
|
|
74
75
|
const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the pane's raw stream, so xterm.js keeps its scrollback
|
|
75
76
|
const relatedDocs = require('./lib/related-docs'); // which documents a change puts out of step (R5/R6)
|
|
76
77
|
const applyChange = require('./lib/apply-change'); // anchored + whole-document saves, refuse-on-stale
|
|
@@ -312,11 +313,18 @@ function repoIdentity(root) {
|
|
|
312
313
|
const now = Date.now();
|
|
313
314
|
if (hit && now - hit.at < REPO_KEY_TTL_MS) return hit;
|
|
314
315
|
let key = root, name = path.basename(root), branch = null, branchError = null, branchCode = null;
|
|
315
|
-
let remote = null;
|
|
316
|
+
let remote = null, tree = null;
|
|
316
317
|
try {
|
|
317
318
|
const trees = gitInfo.worktrees(root);
|
|
318
319
|
if (trees.length && trees[0].dir) { key = path.resolve(trees[0].dir); name = path.basename(key); }
|
|
319
320
|
} catch { /* not a repo, or git absent — the tree is its own group, which is the truth */ }
|
|
321
|
+
// The THIRD key, and the only one that answers "would these two sessions collide": the working
|
|
322
|
+
// tree this workspace sits in — one HEAD, one index, one set of files. `key` above is the
|
|
323
|
+
// REPOSITORY (its main worktree), which deliberately groups a repo's linked worktrees; those are
|
|
324
|
+
// exactly the sessions that are safely isolated, so it is the wrong key for collisions. Null for
|
|
325
|
+
// a folder with no git, and null is meaningful: no index, nothing to collide over. Memoised in
|
|
326
|
+
// this same entry because `wsList()` runs on every workspace push and this is a third shell-out.
|
|
327
|
+
try { tree = gitInfo.repoRoot(root); } catch { tree = null; }
|
|
320
328
|
// Where this tree came FROM. Memoised with the rest of the identity (same 15s TTL) because it is
|
|
321
329
|
// another `git` shell-out on a call that runs for every workspace on every push. A failure is not
|
|
322
330
|
// a fault: a folder registered by path legitimately has no origin, and null is the honest answer.
|
|
@@ -333,10 +341,62 @@ function repoIdentity(root) {
|
|
|
333
341
|
// one drifts.
|
|
334
342
|
else { branchError = bi.error || 'branch could not be read'; branchCode = bi.code || 'git-error'; }
|
|
335
343
|
} catch { branchError = 'branch could not be read'; branchCode = 'git-error'; }
|
|
336
|
-
const val = { at: now, key, name, branch, branchError, branchCode, remote };
|
|
344
|
+
const val = { at: now, key, name, branch, branchError, branchCode, remote, tree };
|
|
337
345
|
repoKeyCache.set(root, val);
|
|
338
346
|
return val;
|
|
339
347
|
}
|
|
348
|
+
|
|
349
|
+
// ── Shared-tree co-tenancy (workspace-session-concurrency) ────────────────────────────────────
|
|
350
|
+
// The rules live in lib/tree-share.js, pure and unit-tested; what lives here is the two things the
|
|
351
|
+
// server owns — the git answer (via the memoised identity above) and the session registry.
|
|
352
|
+
//
|
|
353
|
+
// Held claims, tree top → session id. Only ever BELIEVED while its holder is live (treeShare.
|
|
354
|
+
// writerOf re-checks), so an exited writer leaves the tree vacant rather than leaving a stale name
|
|
355
|
+
// on it. Nothing is promoted automatically: which pane drives is the PM's intention, not ours.
|
|
356
|
+
const treeClaims = new Map();
|
|
357
|
+
/** workspace id → its working-tree top (null when the folder is not in a repo). */
|
|
358
|
+
function treeOfWsMap() {
|
|
359
|
+
const m = new Map();
|
|
360
|
+
for (const w of workspaces.values()) m.set(w.id, repoIdentity(w.root).tree || null);
|
|
361
|
+
return m;
|
|
362
|
+
}
|
|
363
|
+
function treeForWs(wsIdent) {
|
|
364
|
+
const w = wsIdent && workspaces.get(wsIdent);
|
|
365
|
+
return w ? (repoIdentity(w.root).tree || null) : null;
|
|
366
|
+
}
|
|
367
|
+
/** Live sessions sharing a tree, excluding one id. Rows are the registry's own objects. */
|
|
368
|
+
function treeTenants(tree, excludeId) {
|
|
369
|
+
return treeShare.coTenants([...sessions.values()], treeOfWsMap(), tree, excludeId);
|
|
370
|
+
}
|
|
371
|
+
function treeWriter(tree) {
|
|
372
|
+
return treeShare.writerOf(treeClaims, treeOfWsMap(), [...sessions.values()], tree);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* A freshly-live session meets its tree: take the claim if nobody holds one, and write what it
|
|
376
|
+
* found into its own scrollback. The pane line is deliberately part of the session's record rather
|
|
377
|
+
* than only a transient notice frame — re-open the tab tomorrow and it still says what it was told.
|
|
378
|
+
* Returns the co-tenants so the caller can put the same fact on the wire.
|
|
379
|
+
*/
|
|
380
|
+
function announceTree(s) {
|
|
381
|
+
const tree = treeForWs(s.ws);
|
|
382
|
+
s.tree = tree || null;
|
|
383
|
+
if (!tree) return [];
|
|
384
|
+
const mates = treeTenants(tree, s.id);
|
|
385
|
+
const holder = treeWriter(tree);
|
|
386
|
+
if (!holder) treeClaims.set(tree, s.id); // vacant → this session declares itself
|
|
387
|
+
if (!mates.length) return []; // alone in the tree: nothing to say
|
|
388
|
+
const held = holder ? (sessions.get(holder) || {}).name : null;
|
|
389
|
+
const line = treeShare.paneAdvisory(mates, { role: holder ? 'reader' : 'writer', writerName: held });
|
|
390
|
+
if (line) appendBuf(s, line);
|
|
391
|
+
return mates;
|
|
392
|
+
}
|
|
393
|
+
/** The sentence for the spawn's notice frame — same facts as the pane line, same source. */
|
|
394
|
+
function treeNoticeFor(s, mates) {
|
|
395
|
+
if (!mates || !mates.length) return null;
|
|
396
|
+
const holder = treeWriter(s.tree);
|
|
397
|
+
const held = holder && holder !== s.id ? (sessions.get(holder) || {}).name : null;
|
|
398
|
+
return treeShare.coTenancyNotice(mates, { role: holder === s.id ? 'writer' : 'reader', writerName: held });
|
|
399
|
+
}
|
|
340
400
|
function wsList() {
|
|
341
401
|
return [...workspaces.values()].map((w) => {
|
|
342
402
|
const rid = repoIdentity(w.root);
|
|
@@ -523,6 +583,10 @@ const ROLE_BY_DIR = {
|
|
|
523
583
|
prd: 'PRD', evals: 'eval', design: 'design', research: 'research', briefs: 'brief',
|
|
524
584
|
okrs: 'OKR', radar: 'radar', gtm: 'gtm', 'out-of-scope': 'rejected', 'harness-refine': 'harness',
|
|
525
585
|
'design-export': 'design', explainers: 'explainer', fixtures: 'fixture',
|
|
586
|
+
// The initiative RECORD (planning/initiatives/<id>.md) is the canonical anchor+lane carrier and
|
|
587
|
+
// the only artifact that exists for work backfilled from a merge. Before it was a role, a record
|
|
588
|
+
// with no sibling PRD landed in `ungrouped` and the initiative was invisible here.
|
|
589
|
+
initiatives: 'record',
|
|
526
590
|
};
|
|
527
591
|
// ── D60 specimens: prototype/<id>/, deliberately OUTSIDE planning/ ───────────────────────────
|
|
528
592
|
// The planning walk cannot reach them (planningRoots only finds `planning`), so a prototype leg was
|
|
@@ -575,17 +639,23 @@ function parseHead(full) {
|
|
|
575
639
|
head = buf.slice(0, n).toString('utf8');
|
|
576
640
|
} catch { return {}; }
|
|
577
641
|
const id = head.match(/^\s*[-*]?\s*initiative_id\s*:\s*["']?([A-Za-z0-9][\w.-]*)["']?\s*$/m);
|
|
642
|
+
// An initiative RECORD names itself with `id:`, not `initiative_id:`. Only the record role reads
|
|
643
|
+
// this (see docMeta) — a bare `id:` on any other document must not invent an initiative.
|
|
644
|
+
const rid = head.match(/^\s*id\s*:\s*["']?([A-Za-z0-9][\w.-]*)["']?\s*$/m);
|
|
578
645
|
const h1 = head.match(/^#\s+(.+)$/m);
|
|
579
|
-
//
|
|
580
|
-
//
|
|
646
|
+
// The anchor and the lane are DECLARED state and are read as such — they say what the work is
|
|
647
|
+
// pointed at, never how far along it is. `status:`/`stage:` are deliberately NOT read: they were
|
|
648
|
+
// hand-typed free text (23 distinct values in this repo, 33 absent, several truncated mid-YAML)
|
|
649
|
+
// funnelled through a regex, so `build complete, PR #138` rendered as `building`. Position is
|
|
650
|
+
// derived from evidence in deliveryIndex/deriveState instead — see the surface explainer.
|
|
581
651
|
const clean = (m) => m ? m[1].replace(/\*\*/g, '').split(/\s+[—–-]\s|\(/)[0].trim().slice(0, 48) : null;
|
|
582
|
-
const status = head.match(/^\s*[-*]?\s*status\s*:\s*(.+?)\s*$/mi);
|
|
583
|
-
const stage = head.match(/^\s*[-*]?\s*stage\s*:\s*(.+?)\s*$/mi);
|
|
584
652
|
const kr = head.match(/^\s*[-*]?\s*parent[_-]?kr\s*:\s*(.+?)\s*$/mi);
|
|
653
|
+
const lane = head.match(/^\s*[-*]?\s*lane\s*:\s*(.+?)\s*$/mi);
|
|
585
654
|
return {
|
|
586
655
|
initiative: id ? id[1] : null,
|
|
656
|
+
recordId: rid ? rid[1] : null,
|
|
587
657
|
title: h1 ? h1[1].replace(/^(PRD|Eval Rubric|Design Brief)\s*:\s*/i, '').trim() : null,
|
|
588
|
-
|
|
658
|
+
kr: clean(kr), lane: clean(lane),
|
|
589
659
|
};
|
|
590
660
|
}
|
|
591
661
|
function roleAndStem(rel, base) {
|
|
@@ -608,8 +678,9 @@ function docMeta(root, full) {
|
|
|
608
678
|
const meta = {
|
|
609
679
|
mtimeMs: st.mtimeMs, path: rel, name: base, role,
|
|
610
680
|
// frontmatter wins; the filename convention is the fallback, never the override
|
|
611
|
-
initiative: head.initiative ||
|
|
612
|
-
|
|
681
|
+
initiative: head.initiative || (role === 'record' ? head.recordId : null) || null,
|
|
682
|
+
stem, title: head.title || null,
|
|
683
|
+
kr: head.kr || null, lane: head.lane || null,
|
|
613
684
|
};
|
|
614
685
|
metaCache.set(full, meta);
|
|
615
686
|
return meta;
|
|
@@ -650,17 +721,17 @@ function initiativeIndex(root) {
|
|
|
650
721
|
const k = keyFor(m);
|
|
651
722
|
const art = { name: m.name, path: m.path, role: m.role, title: m.title };
|
|
652
723
|
if (!k) { ungrouped.push(art); continue; }
|
|
653
|
-
if (!groups.has(k)) groups.set(k, { id: k, title: null,
|
|
724
|
+
if (!groups.has(k)) groups.set(k, { id: k, title: null, kr: null, lane: null, artifacts: [] });
|
|
654
725
|
const g = groups.get(k);
|
|
655
726
|
g.artifacts.push(art);
|
|
656
727
|
if (m.role === 'PRD' && m.title) g.title = m.title; // the PRD names the initiative
|
|
657
728
|
else if (!g.title && m.title) g.title = m.title;
|
|
658
|
-
//
|
|
659
|
-
|
|
660
|
-
if (m.
|
|
661
|
-
if (m.
|
|
729
|
+
// The anchor and lane are the RECORD's to declare (D56/D59 — it is what the gates read); the
|
|
730
|
+
// PRD fills a gap for legacy initiatives that predate repo-native records, never overrides it.
|
|
731
|
+
if (m.kr && (m.role === 'record' || !g.kr)) g.kr = m.kr;
|
|
732
|
+
if (m.lane && (m.role === 'record' || !g.lane)) g.lane = m.lane;
|
|
662
733
|
}
|
|
663
|
-
const ROLE_ORDER = ['PRD', 'rubric', 'eval', 'design brief', 'design', 'reconcile', 'dogfood'];
|
|
734
|
+
const ROLE_ORDER = ['record', 'PRD', 'rubric', 'eval', 'design brief', 'design', 'reconcile', 'dogfood'];
|
|
664
735
|
const initiatives = [...groups.values()].map((g) => {
|
|
665
736
|
g.artifacts.sort((a, b) => {
|
|
666
737
|
const ia = ROLE_ORDER.indexOf(a.role), ib = ROLE_ORDER.indexOf(b.role);
|
|
@@ -695,57 +766,207 @@ function library(root) {
|
|
|
695
766
|
// client renders it; a surface that can't be produced returns ok:false and the client shows an
|
|
696
767
|
// honest can't-produce state. RICE/effort/milestones/progress are NET-NEW state → emitted as null
|
|
697
768
|
// ("— not tracked"), never invented (D14/D40 honesty).
|
|
698
|
-
const PHASE_OF = { planning: 'plan', building: 'deliver', blocked: 'deliver',
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
769
|
+
const PHASE_OF = { planning: 'plan', building: 'deliver', blocked: 'deliver', delivered: 'report', accepted: 'report', unknown: 'plan' };
|
|
770
|
+
|
|
771
|
+
// ── Delivery evidence (board-delivery-truth R-2/R-3). ──────────────────────────────────────────
|
|
772
|
+
// "Has this shipped?" answered from the trunk's own history rather than from anything anyone typed.
|
|
773
|
+
//
|
|
774
|
+
// ONE `git log` per trunk head, memoised on that sha — ~0.3s and ~800 KB on this repo's 1,214
|
|
775
|
+
// commits, paid once and reused by every surface until the trunk moves. Local only: git reads the
|
|
776
|
+
// object store on disk, so the suite's "no network" guarantee (K. Surfaces) is untouched.
|
|
777
|
+
//
|
|
778
|
+
// TWO signals, because one is not enough. The branch convention (`initiative/<id>`) is precise but
|
|
779
|
+
// incomplete: 31% of this repo's merged PRs shipped on `claude/*` branches, which match no id. So a
|
|
780
|
+
// commit that MENTIONS the id and TOUCHES a build path counts too. The build-path requirement is
|
|
781
|
+
// what keeps the mention signal honest — `feat(planning): add <id> PRD` mentions the id while
|
|
782
|
+
// delivering nothing, and without the path test every specified initiative would read as shipped.
|
|
783
|
+
const BUILD_EXCLUDE = /^(planning|okf|prototype)\//;
|
|
784
|
+
function isDeliveryPath(f) {
|
|
785
|
+
if (!f) return false;
|
|
786
|
+
if (BUILD_EXCLUDE.test(f)) return false;
|
|
787
|
+
return !/^[^/]+\.md$/.test(f); // root governance prose (DECISIONS.md, CLAUDE.md…) is not a build
|
|
788
|
+
}
|
|
789
|
+
const deliveryCache = new Map(); // root -> { head, index }
|
|
790
|
+
function trunkRef(root) {
|
|
791
|
+
for (const c of ['origin/main', 'main', 'origin/master', 'master']) {
|
|
792
|
+
const r = gitCap(root, ['rev-parse', '--verify', '--quiet', c]);
|
|
793
|
+
if (r.ok && r.out.trim()) return { ref: c, head: r.out.trim() };
|
|
794
|
+
}
|
|
705
795
|
return null;
|
|
706
796
|
}
|
|
707
|
-
function
|
|
708
|
-
|
|
797
|
+
function gitCap(root, args) {
|
|
798
|
+
try {
|
|
799
|
+
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
800
|
+
if (r.error || r.status !== 0) return { ok: false, out: '', err: (r.stderr || (r.error && r.error.message) || '').trim() };
|
|
801
|
+
return { ok: true, out: r.stdout || '' };
|
|
802
|
+
} catch (e) { return { ok: false, out: '', err: String((e && e.message) || e) }; }
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* → { ok, reason?, byBranch:Set<id>, byMention:Set<id> }
|
|
806
|
+
* `ok:false` is a REAL answer and never degrades into "nothing was delivered" — the caller renders
|
|
807
|
+
* it red and marks every initiative's delivery `unknown` (R-3). A board that cannot read the
|
|
808
|
+
* history must say so; silently reporting `planning` for 110 shipped initiatives is the failure
|
|
809
|
+
* this whole initiative exists to remove.
|
|
810
|
+
*/
|
|
811
|
+
function deliveryIndex(root, ids) {
|
|
812
|
+
const t = trunkRef(root);
|
|
813
|
+
if (!t) return { ok: false, reason: 'no trunk branch (main/master) is reachable from this repository — delivery cannot be derived', byBranch: new Set(), byMention: new Set() };
|
|
814
|
+
const hit = deliveryCache.get(root);
|
|
815
|
+
if (hit && hit.head === t.head) return hit.index;
|
|
816
|
+
|
|
817
|
+
// %x1e separates commits, %x1f separates the message from --name-only's file list.
|
|
818
|
+
const r = gitCap(root, ['log', '--format=%x1e%s %b%x1f', '--name-only', t.head]);
|
|
819
|
+
if (!r.ok) {
|
|
820
|
+
return { ok: false, reason: `git could not read the history at ${t.ref}${r.err ? ` — ${r.err}` : ''}`, byBranch: new Set(), byMention: new Set() };
|
|
821
|
+
}
|
|
822
|
+
const byBranch = new Set(), byMention = new Set();
|
|
823
|
+
const known = Array.isArray(ids) ? ids : [...ids];
|
|
824
|
+
for (const chunk of r.out.split('\x1e')) {
|
|
825
|
+
if (!chunk.trim()) continue;
|
|
826
|
+
const cut = chunk.indexOf('\x1f');
|
|
827
|
+
const msg = cut < 0 ? chunk : chunk.slice(0, cut);
|
|
828
|
+
const files = cut < 0 ? [] : chunk.slice(cut + 1).split('\n').map((s) => s.trim()).filter(Boolean);
|
|
829
|
+
for (const m of msg.matchAll(/initiative\/([A-Za-z0-9][\w.-]*)/g)) byBranch.add(m[1]);
|
|
830
|
+
if (!files.some(isDeliveryPath)) continue;
|
|
831
|
+
for (const id of known) if (id.length > 3 && msg.includes(id)) byMention.add(id);
|
|
832
|
+
}
|
|
833
|
+
const index = { ok: true, byBranch, byMention };
|
|
834
|
+
deliveryCache.set(root, { head: t.head, index });
|
|
835
|
+
return index;
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* The five states, in precedence order. Every one of them names the artifact it was read from, so
|
|
839
|
+
* the surface can show its own evidence instead of asking to be trusted.
|
|
840
|
+
*
|
|
841
|
+
* `blocked` deliberately outranks `delivered`: work the PM RETURNED is work that needs them, and
|
|
842
|
+
* hiding that behind a delivery glyph would reproduce, in a new place, the exact failure of a board
|
|
843
|
+
* that reports green over a red underneath. (The PRD's R-1 table lists delivered first; this is the
|
|
844
|
+
* one ordering departure, made because the alternative buries a returned initiative.)
|
|
845
|
+
*/
|
|
846
|
+
function deriveState(ev) {
|
|
847
|
+
if (ev.accepted) return { state: 'accepted', why: `accepted by the PM${ev.acceptedAt ? ` on ${String(ev.acceptedAt).slice(0, 10)}` : ''}` };
|
|
848
|
+
if (ev.returned) return { state: 'blocked', why: 'the PM returned this at the Acceptance Gate' };
|
|
849
|
+
if (ev.needs) return { state: 'blocked', why: `${ev.needs} open item${ev.needs > 1 ? 's' : ''} in needs.json` };
|
|
850
|
+
// Not `planning`. An unreadable history means the evidence for the delivery states is missing,
|
|
851
|
+
// and `planning` is itself a claim ("nothing has happened") that the missing evidence cannot
|
|
852
|
+
// support. `unknown` is the only honest answer, and it is visibly not a resting state.
|
|
853
|
+
if (ev.delivery === 'unknown') return { state: 'unknown', why: 'the trunk history could not be read — delivery cannot be derived here' };
|
|
854
|
+
if (ev.deliveredBy) return { state: 'delivered', why: ev.deliveredBy === 'branch' ? 'an initiative/<id> branch merged to the trunk' : 'a commit on the trunk names this initiative and changes build files' };
|
|
855
|
+
if (ev.runs) return { state: 'building', why: `${ev.runs} run${ev.runs > 1 ? 's' : ''} logged, nothing merged yet` };
|
|
856
|
+
return { state: 'planning', why: 'no run, no merge, no acceptance — nothing has happened yet' };
|
|
857
|
+
}
|
|
858
|
+
// BOTH acceptance ledgers, oldest-wins-never: the workspace writes to its state dir, while the
|
|
859
|
+
// ladder and every hand-recorded acceptance in pmos-self live in planning/runs/. Reading only the
|
|
860
|
+
// state dir is why this surface reported 0 accepted against 23 real acceptance rows — the same
|
|
861
|
+
// "one field, several readers, none agreeing" defect the anchor resolver was built to end.
|
|
862
|
+
function acceptanceFilesFor(w) {
|
|
863
|
+
const out = [];
|
|
864
|
+
const d = stateDirFor(w);
|
|
865
|
+
if (d) out.push(path.join(d, 'acceptances.jsonl'));
|
|
866
|
+
if (w && w.root) out.push(path.join(w.root, 'planning', 'runs', 'acceptances.jsonl'));
|
|
867
|
+
return out;
|
|
709
868
|
}
|
|
710
869
|
function readAcceptances(w) {
|
|
711
|
-
const acc = new Map(); // initiative_id ->
|
|
712
|
-
const f
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
for (const line of
|
|
870
|
+
const acc = new Map(); // initiative_id -> { action, at }
|
|
871
|
+
for (const f of acceptanceFilesFor(w)) {
|
|
872
|
+
let text = '';
|
|
873
|
+
try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
|
|
874
|
+
for (const line of text.split('\n')) {
|
|
716
875
|
if (!line.trim()) continue;
|
|
717
876
|
// Tolerate both shapes: this server's {action} and the kit's {accepted} (D54 adapter).
|
|
877
|
+
// THE BOOLEAN OUTRANKS THE LABEL. `action` is an open vocabulary describing what the PM did
|
|
878
|
+
// — this corpus holds accept, merge, publish, return — while `accepted` is the D12 verdict
|
|
879
|
+
// itself. Reading `action` first (the previous behaviour) meant every row labelled `merge` or
|
|
880
|
+
// `publish` carried `accepted: true` and was still not counted: 7 of 16 real acceptances went
|
|
881
|
+
// missing, and the surface reported those initiatives as merely `building`.
|
|
718
882
|
try {
|
|
719
883
|
const r = JSON.parse(line);
|
|
720
884
|
if (!r.initiative_id) continue;
|
|
721
|
-
|
|
885
|
+
const label = String(r.action || '').toLowerCase();
|
|
886
|
+
const action = r.accepted === true ? 'accept'
|
|
887
|
+
: r.accepted === false ? 'return'
|
|
888
|
+
: label === 'return' ? 'return'
|
|
889
|
+
: ['accept', 'approve', 'merge', 'publish'].includes(label) ? 'accept'
|
|
890
|
+
: null;
|
|
891
|
+
const at = r.at || r.date || null;
|
|
892
|
+
// Last write wins, but a row with no timestamp never displaces a dated one.
|
|
893
|
+
const prev = acc.get(r.initiative_id);
|
|
894
|
+
if (prev && prev.at && !at) continue;
|
|
895
|
+
acc.set(r.initiative_id, { action, at });
|
|
722
896
|
} catch {}
|
|
723
897
|
}
|
|
724
|
-
}
|
|
898
|
+
}
|
|
725
899
|
return acc;
|
|
726
900
|
}
|
|
901
|
+
// How many runs each initiative has logged — evidence that work STARTED, which is the only thing
|
|
902
|
+
// separating `building` from `planning`. The file is the same one the ladder reads.
|
|
903
|
+
function readRunCounts(w) {
|
|
904
|
+
const counts = new Map();
|
|
905
|
+
const d = stateDirFor(w);
|
|
906
|
+
const files = [];
|
|
907
|
+
if (d) files.push(path.join(d, 'agent-runs.jsonl'));
|
|
908
|
+
if (w && w.root) files.push(path.join(w.root, 'planning', 'runs', 'agent-runs.jsonl'));
|
|
909
|
+
for (const f of files) {
|
|
910
|
+
let text = '';
|
|
911
|
+
try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
|
|
912
|
+
for (const line of text.split('\n')) {
|
|
913
|
+
if (!line.trim()) continue;
|
|
914
|
+
try {
|
|
915
|
+
const r = JSON.parse(line);
|
|
916
|
+
if (r && r.initiative_id) counts.set(r.initiative_id, (counts.get(r.initiative_id) || 0) + 1);
|
|
917
|
+
} catch {}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
return counts;
|
|
921
|
+
}
|
|
727
922
|
function surfaceInitiatives(root, w) {
|
|
728
923
|
const { initiatives } = initiativeIndex(root);
|
|
729
924
|
const acc = readAcceptances(w);
|
|
925
|
+
const runs = readRunCounts(w);
|
|
926
|
+
const needsRaw = readNeeds(w) || [];
|
|
927
|
+
const needsBy = new Map();
|
|
928
|
+
for (const n of needsRaw) if (n && n.initiative_id) needsBy.set(n.initiative_id, (needsBy.get(n.initiative_id) || 0) + 1);
|
|
929
|
+
|
|
930
|
+
const del = deliveryIndex(root, initiatives.map((g) => g.id));
|
|
730
931
|
const items = initiatives.map((g) => {
|
|
731
|
-
const
|
|
932
|
+
const a = acc.get(g.id) || {};
|
|
933
|
+
const deliveredBy = !del.ok ? null : del.byBranch.has(g.id) ? 'branch' : del.byMention.has(g.id) ? 'mention' : null;
|
|
934
|
+
const { state, why } = deriveState({
|
|
935
|
+
accepted: a.action === 'accept' || a.action === 'approve', acceptedAt: a.at,
|
|
936
|
+
returned: a.action === 'return',
|
|
937
|
+
needs: needsBy.get(g.id) || 0,
|
|
938
|
+
delivery: del.ok ? 'known' : 'unknown',
|
|
939
|
+
deliveredBy,
|
|
940
|
+
runs: runs.get(g.id) || 0,
|
|
941
|
+
});
|
|
732
942
|
return {
|
|
733
943
|
id: g.id, title: g.title || null,
|
|
734
|
-
status:
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
944
|
+
status: state, statusText: why, phase: PHASE_OF[state] || 'plan',
|
|
945
|
+
// `stage` was the declared field this surface used to trust. It is gone, not renamed —
|
|
946
|
+
// the client renders `statusText`, which always names the artifact the state was read from.
|
|
947
|
+
stage: null, kr: g.kr || null, lane: g.lane || null,
|
|
948
|
+
accepted: state === 'accepted',
|
|
949
|
+
delivered: state === 'accepted' || state === 'delivered',
|
|
950
|
+
delivery_evidence: deliveredBy,
|
|
951
|
+
artifacts: g.artifacts.map((a2) => ({ role: a2.role, name: a2.name, path: a2.path })),
|
|
738
952
|
// NET-NEW state — not on disk yet, so null (the surface renders "— not tracked", never a guess)
|
|
739
953
|
rice: null, effort: null, milestones: null, progress: null, tracked: false,
|
|
740
954
|
};
|
|
741
955
|
});
|
|
742
956
|
const by = (k) => items.filter((i) => i.status === k).length;
|
|
743
|
-
const needs =
|
|
957
|
+
const needs = needsRaw.map((n) => ({
|
|
744
958
|
t: n.question || '(unstated)', kind: n.kind || 'question', initiative_id: n.initiative_id || null,
|
|
745
959
|
}));
|
|
746
960
|
return {
|
|
747
961
|
initiatives: items,
|
|
748
|
-
pulse: {
|
|
962
|
+
pulse: {
|
|
963
|
+
total: items.length, planning: by('planning'), building: by('building'),
|
|
964
|
+
blocked: by('blocked'), delivered: by('delivered'), accepted: by('accepted'),
|
|
965
|
+
unknown: by('unknown'),
|
|
966
|
+
},
|
|
967
|
+
// R-3: a failed read is reported, never absorbed. The client renders this RED above the list
|
|
968
|
+
// rather than letting 110 shipped initiatives quietly read `planning`.
|
|
969
|
+
delivery_read: del.ok ? null : (del.reason || 'the trunk history could not be read'),
|
|
749
970
|
needs,
|
|
750
971
|
};
|
|
751
972
|
}
|
|
@@ -755,7 +976,7 @@ function surfaceRoadmap(root, w) {
|
|
|
755
976
|
label, dates_tracked: false,
|
|
756
977
|
items: items.filter((i) => keys.includes(i.status)).map((i) => ({ id: i.id, status: i.status, phase: i.phase, progress: i.progress })),
|
|
757
978
|
});
|
|
758
|
-
const groups = [bucket('In flight', ['building', 'blocked']), bucket('Planned', ['planning']), bucket('Landed', ['
|
|
979
|
+
const groups = [bucket('In flight', ['building', 'blocked']), bucket('Planned', ['planning']), bucket('Landed', ['delivered', 'accepted'])]
|
|
759
980
|
.filter((g) => g.items.length);
|
|
760
981
|
return { groups, note: 'dates are net-new state — grouped by status until a timeline is tracked' };
|
|
761
982
|
}
|
|
@@ -932,12 +1153,16 @@ function surfaceReport(root, w) {
|
|
|
932
1153
|
const s = surfaceInitiatives(root, w);
|
|
933
1154
|
const line = (i) => `${i.id}${i.title ? ' — ' + i.title : ''}`;
|
|
934
1155
|
const inflight = s.initiatives.filter((i) => i.status === 'building' || i.status === 'blocked');
|
|
935
|
-
|
|
1156
|
+
// Delivered and accepted are reported SEPARATELY, not summed into one "landed" heading. The gap
|
|
1157
|
+
// between them is the D12 Acceptance Gate's backlog — the number this report exists to surface.
|
|
1158
|
+
const delivered = s.initiatives.filter((i) => i.status === 'delivered');
|
|
1159
|
+
const accepted = s.initiatives.filter((i) => i.status === 'accepted');
|
|
936
1160
|
const sections = [
|
|
937
1161
|
{ heading: 'In flight', items: inflight.map(line) },
|
|
938
|
-
{ heading: '
|
|
1162
|
+
{ heading: 'Delivered — acceptance not recorded', items: delivered.map(line) },
|
|
1163
|
+
{ heading: 'Accepted', items: accepted.map(line) },
|
|
939
1164
|
{ heading: 'Needs you', items: s.needs.map((n) => n.t) },
|
|
940
|
-
{ heading: 'Metrics pulse', items: [`${s.pulse.total} initiatives · ${s.pulse.building} building · ${s.pulse.blocked} blocked · ${s.pulse.
|
|
1165
|
+
{ heading: 'Metrics pulse', items: [`${s.pulse.total} initiatives · ${s.pulse.building} building · ${s.pulse.blocked} blocked · ${s.pulse.delivered} delivered · ${s.pulse.accepted} accepted`] },
|
|
941
1166
|
];
|
|
942
1167
|
return { cadence: 'weekly', sections };
|
|
943
1168
|
}
|
|
@@ -1307,7 +1532,16 @@ function sessionList() {
|
|
|
1307
1532
|
// failure to avoid claiming "no profiles are configured on this machine" when PMOS never looked.
|
|
1308
1533
|
const pread = readProfilesNow();
|
|
1309
1534
|
const byId = new Map((pread.profiles || []).map((p) => [p.id, p]));
|
|
1310
|
-
|
|
1535
|
+
// ONE tree map and ONE writer lookup per broadcast, for the same reason profiles are read once:
|
|
1536
|
+
// the heartbeat runs every 2s and a per-row `git rev-parse` would be a shell-out per pane per
|
|
1537
|
+
// tick. `repoIdentity` is memoised, but the map is still built once and reused.
|
|
1538
|
+
const rowsAll = [...sessions.values()];
|
|
1539
|
+
const treeOfWs = treeOfWsMap();
|
|
1540
|
+
const writerByTree = new Map();
|
|
1541
|
+
for (const t of new Set([...treeOfWs.values()].filter(Boolean))) {
|
|
1542
|
+
writerByTree.set(t, treeShare.writerOf(treeClaims, treeOfWs, rowsAll, t));
|
|
1543
|
+
}
|
|
1544
|
+
return rowsAll.map((s) => {
|
|
1311
1545
|
const d = harness.deriveState(s.state, s.lastOutputAt, now);
|
|
1312
1546
|
const h = harnessOf(s);
|
|
1313
1547
|
const hx = transcriptHarness(s);
|
|
@@ -1339,6 +1573,18 @@ function sessionList() {
|
|
|
1339
1573
|
// BARE ID — the module rejects a bare string on purpose ("the recorded value is not a
|
|
1340
1574
|
// profile descriptor"), which is the honest answer. Resolving it to `null` instead would
|
|
1341
1575
|
// print "none was chosen" over a session that did choose one (R9).
|
|
1576
|
+
// ── the shared working tree, and the DECLARED writer on it ──
|
|
1577
|
+
// `tree` is the collision unit (one HEAD, one index) — null for a folder with no git, and
|
|
1578
|
+
// null is the honest answer there rather than a group of one. `tree_role` is 'writer' for the
|
|
1579
|
+
// one session that holds the claim, 'reader' for every other session in that tree, and null
|
|
1580
|
+
// where there is no tree. `tree_peers` counts the OTHER live sessions sharing it. All three
|
|
1581
|
+
// are advisory: PMOS hosts the terminal, it does not sit between the agent and the files.
|
|
1582
|
+
tree: treeOfWs.get(s.ws) || null,
|
|
1583
|
+
// an exited pane holds no role: a corpse is neither writing nor waiting to.
|
|
1584
|
+
tree_role: treeShare.isLive(s)
|
|
1585
|
+
? treeShare.roleOf(s.id, treeOfWs.get(s.ws) || null, writerByTree.get(treeOfWs.get(s.ws)))
|
|
1586
|
+
: null,
|
|
1587
|
+
tree_peers: treeShare.coTenants(rowsAll, treeOfWs, treeOfWs.get(s.ws) || null, s.id).length,
|
|
1342
1588
|
profile_id: 'profile' in s ? (s.profile == null ? null : s.profile) : null,
|
|
1343
1589
|
profile_line: profilesLib.profileLine(
|
|
1344
1590
|
!('profile' in s) ? undefined
|
|
@@ -1757,6 +2003,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
|
|
|
1757
2003
|
s.buf = `\x1b[38;5;203m✗ ${why}\x1b[0m\r\n\x1b[38;5;244mFix the above, then ↻ respawn.\x1b[0m\r\n`;
|
|
1758
2004
|
persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
1759
2005
|
}
|
|
2006
|
+
announceTree(s); // before attach, so the advisory is the first line of the pane's record
|
|
1760
2007
|
attachPty(s);
|
|
1761
2008
|
feed(s);
|
|
1762
2009
|
persist(s); pushSessions(); pushWorkspaces(); return s;
|
|
@@ -1775,6 +2022,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
|
|
|
1775
2022
|
pty: p, tmux: null, note: '', buf: '', cols: 80, rows: 24, clients: new Set(), created: nowISO(),
|
|
1776
2023
|
lastOutputAt: Date.now() };
|
|
1777
2024
|
sessions.set(id, s);
|
|
2025
|
+
announceTree(s); // registered first: a session's co-tenancy question includes itself in the map
|
|
1778
2026
|
p.onData((d) => {
|
|
1779
2027
|
appendBuf(s, d);
|
|
1780
2028
|
for (const c of s.clients) if (c.readyState === 1) c.send(JSON.stringify({ t: 'data', id, data: d }));
|
|
@@ -1852,7 +2100,16 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
1852
2100
|
}
|
|
1853
2101
|
}
|
|
1854
2102
|
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id });
|
|
1855
|
-
|
|
2103
|
+
// The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
|
|
2104
|
+
// block above. The isolate default already computes that other sessions would be disturbed — and
|
|
2105
|
+
// then the whole git block is skipped when no branch was picked, which is the common path and the
|
|
2106
|
+
// one the PM has been living in. It said nothing. Now it says what it already knew.
|
|
2107
|
+
//
|
|
2108
|
+
// Both facts travel: `notice` is a single string on the wire and a branch notice and a co-tenancy
|
|
2109
|
+
// notice can both be true of one spawn. joinNotices keeps them both rather than letting the
|
|
2110
|
+
// second evict the first exactly when the PM most needs both.
|
|
2111
|
+
const shared = treeNoticeFor(s, treeTenants(s.tree, s.id));
|
|
2112
|
+
return { ok: true, session: s, notice: treeShare.joinNotices(notice, shared) };
|
|
1856
2113
|
}
|
|
1857
2114
|
|
|
1858
2115
|
// ── Worktree housekeeping: the app that creates the folders shows and clears them ──────────────
|
|
@@ -1868,7 +2125,11 @@ function worktreeReport(w) {
|
|
|
1868
2125
|
const rows = trees.map((t, i) => {
|
|
1869
2126
|
const main = i === 0;
|
|
1870
2127
|
const owner = [...workspaces.values()].find((x) => path.resolve(x.root) === path.resolve(t.dir));
|
|
1871
|
-
|
|
2128
|
+
// Counted by TREE, not by workspace id. A session running in a workspace registered at a
|
|
2129
|
+
// SUBDIRECTORY of this tree (`repo/packages/app` — the ordinary tenant-repo shape) is a session
|
|
2130
|
+
// whose files this removal would pull out from under it, and the old workspace-id count could
|
|
2131
|
+
// not see it: two ids, one checkout. `owner` is still the row's workspace for the client.
|
|
2132
|
+
const live = treeTenants(path.resolve(t.dir), null).length;
|
|
1872
2133
|
// the main worktree is the repo itself — never removable from here, and saying so beats an
|
|
1873
2134
|
// absent button the PM has to reason about
|
|
1874
2135
|
const gone = !main && !isDir(t.dir);
|
|
@@ -1972,6 +2233,10 @@ function removeSession(id) {
|
|
|
1972
2233
|
if (!s || s.pty) return false;
|
|
1973
2234
|
if (s.tmux) tmuxKillSession(s.tmux); // best-effort: never orphan a detached tmux session
|
|
1974
2235
|
forgetScratchpad(s); // the tab's staging text goes with the tab
|
|
2236
|
+
// Drop a claim this session held. Correctness does not depend on this — treeShare.writerOf only
|
|
2237
|
+
// believes a claim whose holder is still live, so a stale entry already reads as vacant — but a
|
|
2238
|
+
// map that only grows is a leak waiting to be found by someone else.
|
|
2239
|
+
if (s.tree && treeClaims.get(s.tree) === id) treeClaims.delete(s.tree);
|
|
1975
2240
|
sessions.delete(id); unpersist(id); pushSessions(); pushWorkspaces();
|
|
1976
2241
|
return true;
|
|
1977
2242
|
}
|
|
@@ -2782,6 +3047,25 @@ async function handleRequest(req, res) {
|
|
|
2782
3047
|
return serveFile(res, file, null, true);
|
|
2783
3048
|
}
|
|
2784
3049
|
|
|
3050
|
+
if (p === '/api/tree/claim' && req.method === 'POST') {
|
|
3051
|
+
// Hand the writer claim to a named session. The ONLY way a claim moves: never on a timer, never
|
|
3052
|
+
// on an exit, never on a heuristic. Which pane should be driving is the PM's intention, and a
|
|
3053
|
+
// derived answer must not impersonate a decision — so an exited holder leaves the tree vacant
|
|
3054
|
+
// and this endpoint is how it stops being vacant.
|
|
3055
|
+
let body = {};
|
|
3056
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
3057
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
3058
|
+
const s = body.session && sessions.get(String(body.session));
|
|
3059
|
+
if (!s) return json(res, { ok: false, error: 'no such session' }, 400);
|
|
3060
|
+
if (!treeShare.isLive(s)) return json(res, { ok: false, error: 'that session has exited — an exited pane cannot hold a claim' }, 400);
|
|
3061
|
+
const tree = treeForWs(s.ws);
|
|
3062
|
+
if (!tree) return json(res, { ok: false, error: 'that session is not in a git working tree — there is nothing to claim' }, 400);
|
|
3063
|
+
const prev = treeWriter(tree);
|
|
3064
|
+
treeClaims.set(tree, s.id);
|
|
3065
|
+
pushSessions();
|
|
3066
|
+
return json(res, { ok: true, tree, writer: s.id, previous: prev || null, advisory: true,
|
|
3067
|
+
note: 'a declaration, not a guarantee — nothing about either process changed' });
|
|
3068
|
+
}
|
|
2785
3069
|
if (p === '/api/workspaces' && req.method === 'GET') return json(res, { workspaces: wsList() });
|
|
2786
3070
|
if (p === '/api/workspaces' && req.method === 'POST') {
|
|
2787
3071
|
// How `pmos` in another repo becomes another workspace (R1). Local-only, like every
|
|
@@ -3228,6 +3512,27 @@ async function handleRequest(req, res) {
|
|
|
3228
3512
|
if (!w) return json(res, { ok: false, error: 'no workspace' });
|
|
3229
3513
|
return json(res, worktreeReport(w));
|
|
3230
3514
|
}
|
|
3515
|
+
if (p === '/api/tree/peers') {
|
|
3516
|
+
// Who this workspace's sessions actually share mutable git state with, and who is declared to
|
|
3517
|
+
// be driving. `advisory` is a FIELD, not a footnote: a caller that renders this must not be
|
|
3518
|
+
// able to read it as enforcement without having read the word.
|
|
3519
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
3520
|
+
if (!w) return json(res, { ok: false, error: 'no workspace' });
|
|
3521
|
+
const tree = repoIdentity(w.root).tree || null;
|
|
3522
|
+
if (!tree) {
|
|
3523
|
+
return json(res, { ok: true, tree: null, writer: null, sessions: [], advisory: true,
|
|
3524
|
+
note: 'this workspace is not inside a git working tree — there is no shared index to collide over' });
|
|
3525
|
+
}
|
|
3526
|
+
const writer = treeWriter(tree);
|
|
3527
|
+
const rows = treeTenants(tree, null).map((s) => ({
|
|
3528
|
+
id: s.id, name: s.name, ws: s.ws, agent: s.agent, state: s.state,
|
|
3529
|
+
role: treeShare.roleOf(s.id, tree, writer),
|
|
3530
|
+
}));
|
|
3531
|
+
return json(res, { ok: true, tree, writer, sessions: rows, advisory: true,
|
|
3532
|
+
note: writer
|
|
3533
|
+
? 'the writer claim is a declaration, not a guarantee — PMOS hosts the terminal, it does not sit between the agent and the files'
|
|
3534
|
+
: 'no session holds the writer claim on this tree' });
|
|
3535
|
+
}
|
|
3231
3536
|
// The pad belongs to a SESSION when one is named — and the workspace then comes from that
|
|
3232
3537
|
// session, not from the caller, so a stale `ws` can never file one tab's text under another
|
|
3233
3538
|
// repo. `session` omitted still addresses the workspace pad (a window with no tabs yet).
|
|
@@ -3608,10 +3913,27 @@ async function handleRequest(req, res) {
|
|
|
3608
3913
|
'and useful answer.',
|
|
3609
3914
|
].filter((l) => l !== '').join('\n');
|
|
3610
3915
|
|
|
3916
|
+
// NOT typed blind into the PTY. An agent CLI opening a folder it has never seen shows a
|
|
3917
|
+
// trust prompt first — and a fresh tenant repo is by definition such a folder, so the case this
|
|
3918
|
+
// feature exists for is the exact case where blind input is swallowed by a security dialog and
|
|
3919
|
+
// the drafting silently never happens. Found by installing the published kit and watching a real
|
|
3920
|
+
// session sit at "Is this a project you trust?" with the prompt already gone.
|
|
3921
|
+
//
|
|
3922
|
+
// Auto-answering that dialog is not on the table: it is the PM's decision, not ours. So the
|
|
3923
|
+
// prompt is PERSISTED and STAGED instead — it cannot be lost, and the PM sends it once the agent
|
|
3924
|
+
// is actually at its input.
|
|
3925
|
+
const promptFile = path.join(adir, 'prompt-' + Date.now() + '.md');
|
|
3926
|
+
try { fs.writeFileSync(promptFile, prompt + '\n'); }
|
|
3927
|
+
catch (e) { warnWriteFail('alignment prompt', e); return json(res, { ok: false, error: 'could not stage the drafting prompt' }, 500); }
|
|
3928
|
+
|
|
3611
3929
|
const s = spawnSession({ name: 'align: ' + path.basename(rel), agent: 'claude', ws: w.id,
|
|
3612
|
-
origin: 'align:' + rel
|
|
3930
|
+
origin: 'align:' + rel });
|
|
3613
3931
|
const started = !!(s && s.pty);
|
|
3932
|
+
let staged = false;
|
|
3933
|
+
if (started) { try { writeScratchpad(w, s.id, prompt); staged = true; } catch {} }
|
|
3614
3934
|
return json(res, { ok: started, sessionId: s && s.id, targets, sink: adir,
|
|
3935
|
+
prompt_file: promptFile, staged,
|
|
3936
|
+
next: started ? 'the drafting prompt is staged in this session\'s scratchpad — answer any trust prompt the agent shows, then send it' : undefined,
|
|
3615
3937
|
error: started ? undefined : ((s && s.note) || 'could not start the session') });
|
|
3616
3938
|
}
|
|
3617
3939
|
if (p === '/api/align/drafts') {
|
|
@@ -3626,8 +3948,19 @@ async function handleRequest(req, res) {
|
|
|
3626
3948
|
let doc;
|
|
3627
3949
|
try { doc = JSON.parse(fs.readFileSync(path.join(adir, pick), 'utf8')); }
|
|
3628
3950
|
catch { return json(res, { ok: false, error: `the draft file '${pick}' is unreadable or not valid JSON` }, 500); }
|
|
3629
|
-
|
|
3630
|
-
|
|
3951
|
+
// A malformed draft is REFUSED at read, not half-rendered (contract crit 4). Silently dropping a
|
|
3952
|
+
// bad draft would let the agent's mistake reach the PM as a shorter, unexplained list — the same
|
|
3953
|
+
// "looks fine, is wrong" failure the anchored write guard exists to stop. An empty `drafts` array
|
|
3954
|
+
// is valid and calm (crit 3): the agent looked and found nothing to realign.
|
|
3955
|
+
if (!Array.isArray(doc.drafts)) {
|
|
3956
|
+
return json(res, { ok: false, error: `the draft file '${pick}' has no drafts array — the agent did not produce the documented shape` }, 422);
|
|
3957
|
+
}
|
|
3958
|
+
for (const d of doc.drafts) {
|
|
3959
|
+
if (!d || typeof d.file !== 'string' || typeof d.after !== 'string' || (d.before != null && typeof d.before !== 'string')) {
|
|
3960
|
+
return json(res, { ok: false, error: `a draft in '${pick}' is missing a file or an after — refusing to render a partial set (crit 4)` }, 422);
|
|
3961
|
+
}
|
|
3962
|
+
}
|
|
3963
|
+
return json(res, { ok: true, id: pick.replace(/\.json$/, ''), source: doc.source || null, at: doc.at || null, drafts: doc.drafts });
|
|
3631
3964
|
}
|
|
3632
3965
|
if (p === '/api/align/apply' && req.method === 'POST') {
|
|
3633
3966
|
// Apply ONE drafted realignment. Same anchored guard as everything else that writes here: the
|
|
@@ -3954,6 +4287,10 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
|
|
|
3954
4287
|
// non-repo folder from a real git fault, and the whole legibility defect was that distinction
|
|
3955
4288
|
// being dropped one layer above it.
|
|
3956
4289
|
repoIdentity, wsList,
|
|
4290
|
+
// the shared-tree co-tenancy layer — exported so the suite can assert the KEY in both directions
|
|
4291
|
+
// (one repo registered twice is one tree; two worktrees of one repo are two) without driving
|
|
4292
|
+
// every case through a spawn.
|
|
4293
|
+
treeForWs, treeTenants, treeWriter, treeClaims,
|
|
3957
4294
|
// exported so the suite can assert the D08 invariant end-to-end: the harness parsers consume
|
|
3958
4295
|
// scrubbed text, so a secret printed into scrollback must not survive into a rendered value.
|
|
3959
4296
|
scrub, harnessOf,
|
|
@@ -3969,4 +4306,9 @@ module.exports = { server, spawnSession, removeSession, renameSession, sessions,
|
|
|
3969
4306
|
// where the anchor resolver is looked for — exported so the suite can assert the INSTALL
|
|
3970
4307
|
// location is in the list. A dev checkout satisfies entry 1 and would hide the loss of entry 2
|
|
3971
4308
|
// exactly as it hid the workflow engine's absence for four releases (PKG4).
|
|
3972
|
-
ANCHOR_LIB_PATHS
|
|
4309
|
+
ANCHOR_LIB_PATHS,
|
|
4310
|
+
// the delivery derivation (board-delivery-truth), exported so the suite can assert the three
|
|
4311
|
+
// things that matter independently of a rendered page: that no declared field reaches a status,
|
|
4312
|
+
// that an unreadable history reports rather than degrades, and that a planning-only commit does
|
|
4313
|
+
// not count as delivery.
|
|
4314
|
+
surfaceInitiatives, deliveryIndex, deriveState, isDeliveryPath };
|