create-pmos 0.24.0 → 0.26.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 +27 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/README.md +16 -11
- package/workspace/lib/four-panel.js +201 -6
- package/workspace/lib/working-set.js +124 -28
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/data.js +95 -15
- package/workspace/public/pm/panel-detail.js +501 -95
- package/workspace/public/pm/panel-initiatives.js +124 -16
- package/workspace/public/pm/state.js +11 -0
- package/workspace/public/pm/tab-term.js +13 -1
- package/workspace/server.js +169 -6
|
@@ -45,8 +45,21 @@
|
|
|
45
45
|
* Three rules hold it honest. Rows are DUPLICATED, never moved — an initiative keeps its place under
|
|
46
46
|
* its own anchor, because the anchor is the truth (D59) and a shortcut that re-homes work is a second
|
|
47
47
|
* filing system. The group is ABSENT, not empty, when nothing is live. And the join is
|
|
48
|
-
* lib/working-set.js's
|
|
49
|
-
*
|
|
48
|
+
* lib/working-set.js's — so a session the join cannot reach never lands on a row it does not belong
|
|
49
|
+
* to.
|
|
50
|
+
*
|
|
51
|
+
* ── What that first cut got wrong (workspace-session-attribution) ────────────────────────────
|
|
52
|
+
* It joined on the `initiative/<id>` branch convention ALONE, which holds in one of four ordinary
|
|
53
|
+
* situations — and the proof was this file's own history: the initiative that shipped this group was
|
|
54
|
+
* built on a `claude/*` branch, so the group could not see the session that wrote it. A session now
|
|
55
|
+
* RECORDS what it is for, and `attributeSessions` resolves recorded → branch → unattributed, saying
|
|
56
|
+
* which path answered.
|
|
57
|
+
*
|
|
58
|
+
* The other half was worse than a narrow join. A session the board could not place was DROPPED, so a
|
|
59
|
+
* running agent was invisible on the surface whose job is to say what is running. Refusing to guess
|
|
60
|
+
* and refusing to mention are different things, and only the first is honesty — hence `orphanRows`,
|
|
61
|
+
* which lists them under the group, claims no initiative for them, and offers the bind that turns
|
|
62
|
+
* the PM's knowledge into a recorded declaration.
|
|
50
63
|
*
|
|
51
64
|
* Fixture mode is structurally unaffected, not conditionally suppressed: `pollSessions` returns early
|
|
52
65
|
* on FIXTURE and `foldWorkspaces` never runs, so `state.sessions` is empty and there are no branches.
|
|
@@ -148,11 +161,26 @@ const branches = () => (state.wsBranches && typeof state.wsBranches === 'object'
|
|
|
148
161
|
* `return` two modules away.
|
|
149
162
|
*/
|
|
150
163
|
function liveSessions() {
|
|
151
|
-
|
|
164
|
+
return attribution().byInitiative;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The full attribution — `{byInitiative, via, unattributed}` — computed ONCE per render and shared
|
|
169
|
+
* by the row markers, the working-set group, the unattributed row and the search. Deriving it three
|
|
170
|
+
* times would be three chances to disagree about one fact.
|
|
171
|
+
*
|
|
172
|
+
* The join now prefers what a session RECORDED over what its branch is called; `via` says which
|
|
173
|
+
* answered, and `unattributed` is every live session neither path could place. That last list is
|
|
174
|
+
* why this returns the whole shape rather than just the map: a running agent PMOS cannot attribute
|
|
175
|
+
* still has to be visible somewhere, and dropping it was half the defect this fixes.
|
|
176
|
+
*/
|
|
177
|
+
function attribution() {
|
|
178
|
+
const empty = { byInitiative: {}, via: {}, unattributed: [] };
|
|
179
|
+
if (FIXTURE) return empty;
|
|
152
180
|
const ws = WS();
|
|
153
|
-
if (!ws) return
|
|
181
|
+
if (!ws || typeof ws.attributeSessions !== 'function') return empty;
|
|
154
182
|
const map = branches();
|
|
155
|
-
return ws.
|
|
183
|
+
return ws.attributeSessions(state.sessions, (id) => map[id] || null);
|
|
156
184
|
}
|
|
157
185
|
|
|
158
186
|
/**
|
|
@@ -161,13 +189,14 @@ function liveSessions() {
|
|
|
161
189
|
* `onBoard` is what stops a tab or a working tree belonging to ANOTHER repository putting a row on
|
|
162
190
|
* this repository's board — `slugProduct()` is the same map panel 4 scopes its tabs by.
|
|
163
191
|
*/
|
|
164
|
-
function workingSet(
|
|
192
|
+
function workingSet(attr, product) {
|
|
165
193
|
if (FIXTURE) return [];
|
|
166
194
|
const ws = WS();
|
|
167
195
|
if (!ws) return [];
|
|
168
196
|
const owner = slugProduct();
|
|
169
197
|
return ws.workingSet({
|
|
170
|
-
live,
|
|
198
|
+
live: attr.byInitiative,
|
|
199
|
+
via: attr.via,
|
|
171
200
|
trees: ws.initiativesWithTree(Object.values(branches())),
|
|
172
201
|
tabs: ws.initiativesWithTab(state.tabs),
|
|
173
202
|
onBoard: (slug) => owner[slug] === product,
|
|
@@ -224,9 +253,18 @@ export function openTab(tab) {
|
|
|
224
253
|
}
|
|
225
254
|
|
|
226
255
|
export function openTerm(slug, sess) {
|
|
256
|
+
/* THE TAB ID, and why a session with no initiative gets a different one.
|
|
257
|
+
*
|
|
258
|
+
* `term:<slug>:<name>` is the id every initiative-opened terminal has always had, and it is what
|
|
259
|
+
* makes `openTab` focus an already-open tab instead of duplicating it. A session PMOS cannot place
|
|
260
|
+
* on an initiative has no slug — and interpolating one anyway produced `term:null:agent 1`, where
|
|
261
|
+
* every unattributed session with the same name would collide on one tab. Keying those by the
|
|
262
|
+
* SERVER's own session id instead is both collision-free and more honest: the tab is a view of
|
|
263
|
+
* that process, not of an initiative it does not belong to. */
|
|
264
|
+
const key = slug ? 'term:' + slug + ':' + sess.name : 'term:sess:' + ((sess && sess.id) || sess.name);
|
|
227
265
|
openTab({
|
|
228
|
-
id:
|
|
229
|
-
repo: slugProduct()[slug], sess: sess.name, state: sess.st, profile: sess.profile || 'claude-work',
|
|
266
|
+
id: key, kind: 'term', title: sess.name, slug: slug || null,
|
|
267
|
+
repo: slug ? slugProduct()[slug] : null, sess: sess.name, state: sess.st, profile: sess.profile || 'claude-work',
|
|
230
268
|
/* The SERVER's session id, when the row that was clicked carries one. This is the exact join:
|
|
231
269
|
* with it, the terminal attaches to precisely the PTY the PM pointed at, and a reload attaches
|
|
232
270
|
* to the same one again. Without it the tab falls back to matching by NAME, and names repeat
|
|
@@ -333,11 +371,67 @@ function groupNode(g, product) {
|
|
|
333
371
|
body.push(el('div', { style: 'padding:5px 12px 6px 27px;font-size:11px;color:var(--muted-foreground);font-style:italic' },
|
|
334
372
|
'nothing filed yet — a successful read of nothing'));
|
|
335
373
|
}
|
|
374
|
+
// Rows this group carries that are NOT initiatives — today, the live sessions PMOS cannot place.
|
|
375
|
+
// Appended by the caller so `groupNode` stays the one renderer and does not learn a second model.
|
|
376
|
+
if (g.after) body.push(g.after);
|
|
336
377
|
}
|
|
337
378
|
|
|
338
379
|
return el('div', { style: 'margin-top:4px' }, groupHeader, body);
|
|
339
380
|
}
|
|
340
381
|
|
|
382
|
+
/* ── the live sessions PMOS cannot place ──────────────────────────────────────────────────────
|
|
383
|
+
*
|
|
384
|
+
* One row per unattributable live session, under the working-set group, in the same row shape the
|
|
385
|
+
* initiatives use so the eye reads one list. It deliberately does NOT look like an initiative row:
|
|
386
|
+
* the id slot carries the session's own name and its glyph is the session state, because this row
|
|
387
|
+
* stands for a process, not for a record.
|
|
388
|
+
*
|
|
389
|
+
* Clicking it opens that session's terminal — the same `openTerm` join every other session click
|
|
390
|
+
* uses, by the server's id, with no `spawn`. `+ bind` is the second control: it turns the PM's
|
|
391
|
+
* knowledge of what the session is for into a recorded declaration, which is the only way a session
|
|
392
|
+
* started before this field existed can ever join an initiative.
|
|
393
|
+
*/
|
|
394
|
+
function orphanRows(rows) {
|
|
395
|
+
const ss = SS();
|
|
396
|
+
const ws = WS();
|
|
397
|
+
const head = el('div', {
|
|
398
|
+
style: 'display:flex;align-items:baseline;gap:6px;padding:6px 12px 3px 27px',
|
|
399
|
+
title: ws ? ws.unattributedTitle(rows) : null,
|
|
400
|
+
},
|
|
401
|
+
el('span', { style: 'flex:none;font-family:var(--mono);font-size:10px;color:var(--warning)' }, iv('⌁')),
|
|
402
|
+
el('span', { style: 'font-size:10.5px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' },
|
|
403
|
+
iv('not on an initiative — ' + rows.length + ' live session' + (rows.length === 1 ? '' : 's'))),
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
const body = rows.map((s) => {
|
|
407
|
+
const st = ss && ss.GLYPH[s.shown] ? s.shown : null;
|
|
408
|
+
return el('button', {
|
|
409
|
+
onClick: () => openTerm(null, { name: s.name, st: s.shown, label: s.label, id: s.id, profile: s.profile_id }),
|
|
410
|
+
style: 'display:flex;align-items:center;gap:7px;width:100%;padding:5px 12px 5px 27px;min-height:32px;border-left:2px solid transparent;background:transparent',
|
|
411
|
+
hover: 'background:var(--accent)',
|
|
412
|
+
title: (s.name || s.id) + ' — ' + (s.label || s.shown || '')
|
|
413
|
+
+ '\nPMOS was never told what this session is for. + bind records it.',
|
|
414
|
+
},
|
|
415
|
+
el('span', { style: 'flex:none;font-size:12px;color:' + (st && ss ? ss.COLOR[st] : 'var(--muted-foreground)') },
|
|
416
|
+
st && ss ? ss.GLYPH[st] : '·'),
|
|
417
|
+
el('span', { style: 'font-family:var(--mono);font-size:12.5px;color:var(--foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' },
|
|
418
|
+
iv(s.name || s.id)),
|
|
419
|
+
el('span', { style: 'margin-left:auto;flex:none;font-size:9px;color:var(--muted-foreground)' }, iv(s.label || s.shown || '')),
|
|
420
|
+
);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const bind = el('button', {
|
|
424
|
+
onClick: () => setState({ bindSess: rows.length === 1 ? rows[0].id : null, bindResult: null, detail: { kind: 'bind' }, col2: true }),
|
|
425
|
+
style: 'display:flex;align-items:center;gap:7px;width:100%;padding:6px 12px 6px 27px;color:var(--muted-foreground)',
|
|
426
|
+
hover: 'color:var(--foreground);background:var(--accent)',
|
|
427
|
+
},
|
|
428
|
+
el('span', { style: 'flex:none;width:9px;text-align:center;font-size:14px' }, '+'),
|
|
429
|
+
el('span', { style: 'font-size:11px' }, 'bind a session to an initiative'),
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
return el('div', null, head, body, bind);
|
|
433
|
+
}
|
|
434
|
+
|
|
341
435
|
/* ── "matches elsewhere" — artifacts and sessions that the group list cannot show ──────────── */
|
|
342
436
|
|
|
343
437
|
/**
|
|
@@ -522,9 +616,10 @@ export function renderInitiatives(host) {
|
|
|
522
616
|
const DATA = records();
|
|
523
617
|
const selSlug = selectedSlug();
|
|
524
618
|
const detKind = (state.detail && state.detail.kind) || '';
|
|
525
|
-
// ONE
|
|
526
|
-
// it
|
|
527
|
-
const
|
|
619
|
+
// ONE attribution per render, shared by the row markers, the working-set group, the unattributed
|
|
620
|
+
// row and the search. Computing it four times would be four chances to disagree about one fact.
|
|
621
|
+
const attr = attribution();
|
|
622
|
+
const live = attr.byInitiative;
|
|
528
623
|
|
|
529
624
|
/** One row's view model. `why` is null everywhere except in the working-set group. */
|
|
530
625
|
const rowOf = (slug, why) => {
|
|
@@ -561,13 +656,26 @@ export function renderInitiatives(host) {
|
|
|
561
656
|
*
|
|
562
657
|
* Absent when empty. Not a "0", not a placeholder: a heading over nothing is a heading the PM
|
|
563
658
|
* learns to skip. */
|
|
564
|
-
const now = workingSet(
|
|
565
|
-
|
|
659
|
+
const now = workingSet(attr, product).filter((e) => !q || e.slug.includes(q));
|
|
660
|
+
/* EVERY LIVE SESSION PMOS CANNOT PLACE (workspace-session-attribution R5).
|
|
661
|
+
*
|
|
662
|
+
* The board shipped joining on the `initiative/<id>` branch alone, so a session on `main`, on a
|
|
663
|
+
* `claude/*` branch, or in a repo with no git simply vanished from it. Refusing to guess which
|
|
664
|
+
* initiative it belongs to is right; refusing to MENTION it is not, and only the first is honesty.
|
|
665
|
+
*
|
|
666
|
+
* It is rendered as a row inside the same group rather than as a group of its own, because it is
|
|
667
|
+
* not an initiative and must not sit in a list keyed by one. `+ bind` opens the chooser that
|
|
668
|
+
* turns it into the PM's own declaration. */
|
|
669
|
+
const orphans = q ? [] : (attr.unattributed || []);
|
|
670
|
+
if (now.length || orphans.length) {
|
|
671
|
+
const rows = now.map((e) => rowOf(e.slug, WS() ? WS().reasonTitle(e) : null));
|
|
566
672
|
kids.push(groupNode({
|
|
567
|
-
gid: 'NOW', dyn: true, idc: FX.C.brand, gname: 'working on',
|
|
673
|
+
gid: 'NOW', dyn: true, idc: FX.C.brand, gname: 'working on',
|
|
674
|
+
gct: String(now.length + orphans.length),
|
|
568
675
|
hasKr: true, krid: '⑂', krname: 'live sessions · checked-out trees · open tabs',
|
|
569
|
-
inits:
|
|
676
|
+
inits: rows,
|
|
570
677
|
isEmpty: false,
|
|
678
|
+
after: orphans.length ? orphanRows(orphans) : null,
|
|
571
679
|
}, product));
|
|
572
680
|
}
|
|
573
681
|
for (const g of groups) kids.push(groupNode(g, product));
|
|
@@ -37,6 +37,12 @@ function initial() {
|
|
|
37
37
|
detail: { kind: 'init' },
|
|
38
38
|
tabs: [], active: null, splitId: null,
|
|
39
39
|
initLinks: {}, artLinks: {}, artSyncOpen: {}, artSyncState: {},
|
|
40
|
+
/* The sync half of panel 3, all per-window and none of it persisted. `artSyncTask` holds the
|
|
41
|
+
* task text the server composed for a direction the PM just chose; `linkGone` holds the
|
|
42
|
+
* references withdrawn since the last hydration, so a retracted row leaves the screen at the
|
|
43
|
+
* click rather than at the next read. Neither is a source of truth — what a link IS and what
|
|
44
|
+
* was synced both come back from the store on the next `/api/initiative`. */
|
|
45
|
+
artSyncTask: {}, linkGone: {},
|
|
40
46
|
extraSess: {}, termHist: {}, startN: 1, docEdits: {}, docModes: {},
|
|
41
47
|
/* `+ session` into a working tree that already has sessions in it opens a CHOOSER instead of
|
|
42
48
|
* spawning (workspace-session-choice): `{slug, tree, branch, peers}` while it is open, null
|
|
@@ -44,6 +50,11 @@ function initial() {
|
|
|
44
50
|
* second click ends it, and anything else disarms. Neither is persisted: a chooser that
|
|
45
51
|
* reappeared on reload would be a question about a moment that has passed. */
|
|
46
52
|
startPrompt: null, endConfirm: null,
|
|
53
|
+
/* The bind screen's three transient bits (workspace-session-attribution R3): which live session
|
|
54
|
+
* is selected, what the PM has typed to find an initiative, and the server's own answer to the
|
|
55
|
+
* last bind. None is persisted — a half-finished declaration restored on reload would be a
|
|
56
|
+
* question about a moment that has passed, and `bindResult` is a report about one request. */
|
|
57
|
+
bindSess: null, bindTo: '', bindResult: null,
|
|
47
58
|
col0: true, col1: true, col2: true, scratchOpen: false, scratchText: '', copied: false,
|
|
48
59
|
flowsMap: null, schedList: null, agentList: null, memory: null,
|
|
49
60
|
savedDocs: {}, scratchCopied: false,
|
|
@@ -360,7 +360,19 @@ function bind(rec) {
|
|
|
360
360
|
SOCK.spawning.set(rec.tab.id, true);
|
|
361
361
|
SOCK.spawnQueue.push(rec.tab.id);
|
|
362
362
|
note(rec, 'pmos: starting a session…', true);
|
|
363
|
-
|
|
363
|
+
/* `initiative` is the ONE field this message was missing (workspace-session-attribution R2).
|
|
364
|
+
* `+ session` is clicked on an initiative row, `openTerm` puts that row's id on the tab as
|
|
365
|
+
* `slug`, and `state.js` persists it — so the answer to "what is this session for?" has been
|
|
366
|
+
* sitting on `rec.tab` at the moment of every spawn, while the message named three other fields.
|
|
367
|
+
* Without it the server could only infer the initiative from the git branch, which is why a
|
|
368
|
+
* session on `main` or on `claude/*` was invisible to the board.
|
|
369
|
+
*
|
|
370
|
+
* A tab with no slug sends nothing extra and the session is unattributed. That is the honest
|
|
371
|
+
* outcome, not a failure: the server never refuses a spawn over a label. */
|
|
372
|
+
const ok = send({
|
|
373
|
+
t: 'new', name: rec.tab.sess, agent: 'shell', ws: wsForTab(rec.tab),
|
|
374
|
+
initiative: rec.tab.slug || null,
|
|
375
|
+
});
|
|
364
376
|
if (!ok) {
|
|
365
377
|
SOCK.spawning.delete(rec.tab.id);
|
|
366
378
|
SOCK.spawnQueue = SOCK.spawnQueue.filter((x) => x !== rec.tab.id);
|
package/workspace/server.js
CHANGED
|
@@ -702,11 +702,21 @@ function readTextDoc(root, rel) {
|
|
|
702
702
|
// falling back to the filename convention when an artifact declares nothing.
|
|
703
703
|
// Ordered LONGEST-DISCRIMINATOR-FIRST: `-copy-brief` must be tested before `-brief`, and
|
|
704
704
|
// `-eval-rubric` before any `-rubric`, because the first match wins and slices the stem.
|
|
705
|
+
//
|
|
706
|
+
// The qualified-brief entries (`-comms-brief`, `-launch-brief`) are here for a reason worth stating,
|
|
707
|
+
// because it is the same reason `-copy-brief` was: a stem ending `-comms-brief` ALSO ends `-brief`,
|
|
708
|
+
// so without its own entry it matches the shorter one and the slice leaves a `-comms` tail on the
|
|
709
|
+
// stem. That tail belongs to the qualifier, not to the initiative, and it is enough to put a comms
|
|
710
|
+
// brief in a different group from the PRD it was written for — which is precisely what the PM
|
|
711
|
+
// reported seeing (PRD, defect 3).
|
|
705
712
|
const ROLE_SUFFIXES = [
|
|
706
713
|
['-eval-rubric', 'rubric'], ['-design-brief', 'design brief'], ['-design-reference', 'design'],
|
|
707
|
-
['-copy-brief', 'copy brief'], ['-
|
|
714
|
+
['-copy-brief', 'copy brief'], ['-comms-brief', 'comms brief'], ['-launch-brief', 'launch brief'],
|
|
715
|
+
['-user-stories', 'user stories'], ['-user-story', 'user stories'], ['-stories', 'user stories'],
|
|
716
|
+
['-one-pager', 'one-pager'], ['-copy', 'copy'],
|
|
708
717
|
['-dogfood', 'dogfood'], ['-reconcile', 'reconcile'], ['-findings', 'findings'],
|
|
709
718
|
['-scoping', 'scoping'], ['-intake', 'intake'], ['-brief', 'brief'], ['-prd', 'PRD'],
|
|
719
|
+
['-spec', 'spec'],
|
|
710
720
|
];
|
|
711
721
|
const ROLE_BY_DIR = {
|
|
712
722
|
prd: 'PRD', evals: 'eval', design: 'design', research: 'research', briefs: 'brief',
|
|
@@ -864,12 +874,34 @@ function initiativeIndex(root) {
|
|
|
864
874
|
for (const m of metas) if (!m.initiative) byStem.set(m.stem, (byStem.get(m.stem) || 0) + 1);
|
|
865
875
|
const groups = new Map();
|
|
866
876
|
const ungrouped = [];
|
|
877
|
+
// PREFIX ATTACHMENT, and the three constraints that keep it from becoming a similarity score.
|
|
878
|
+
//
|
|
879
|
+
// The join above is stem EQUALITY, and equality is the wrong shape for how PMs name files. An
|
|
880
|
+
// initiative `ooh-zbsg-ast1` carries `ooh-zbsg-ast1-design-prd.md`, `ooh-zbsg-ast1-user-stories.md`
|
|
881
|
+
// and `ooh-zbsg-ast1-comms-brief.md`; after the role suffix is sliced those are three DIFFERENT
|
|
882
|
+
// stems, so the initiative's own PRD was attributed to no initiative and never reached panel 3.
|
|
883
|
+
// Measured on this repository one initiative ago: 148 documents attributed to nothing, 31 of them
|
|
884
|
+
// beginning with a declared id.
|
|
885
|
+
//
|
|
886
|
+
// 1. Only EXPLICITLY DECLARED ids may capture (`declared`, not `groups`). A group that exists
|
|
887
|
+
// only because two filenames happened to share a stem was never named by anyone, and giving
|
|
888
|
+
// it the authority to claim documents would let a naming coincidence annex a neighbourhood.
|
|
889
|
+
// 2. LONGEST WINS, so `workspace-session-choice-prd.md` cannot be captured by `workspace-session`
|
|
890
|
+
// when both are declared. Sorted once per call, not per document.
|
|
891
|
+
// 3. It runs LAST, after equality and after the convention join, so it can only move a document
|
|
892
|
+
// that would otherwise be `ungrouped`. It cannot take a document away from a group that has a
|
|
893
|
+
// better claim on it, which is what makes the total document count provably unchanged.
|
|
894
|
+
//
|
|
895
|
+
// The boundary being held here is the one `lib/related-docs.js` holds for the same reason: a
|
|
896
|
+
// concrete tie, never "feels related". `x-` is a tie the PM typed. Similarity is not.
|
|
897
|
+
const capturing = [...declared].sort((a, b) => b.length - a.length);
|
|
898
|
+
const prefixOwner = (stem) => capturing.find((id) => stem.length > id.length + 1 && stem.startsWith(id + '-')) || null;
|
|
867
899
|
const keyFor = (m) => {
|
|
868
900
|
if (m.initiative) return m.initiative;
|
|
869
901
|
if (declared.has(m.stem)) return m.stem;
|
|
870
902
|
// the convention join: `jira-sync-prd.md` + `jira-sync-eval-rubric.md` are one unit
|
|
871
903
|
if ((byStem.get(m.stem) || 0) > 1) return m.stem;
|
|
872
|
-
return
|
|
904
|
+
return prefixOwner(m.stem);
|
|
873
905
|
};
|
|
874
906
|
for (const m of metas) {
|
|
875
907
|
const k = keyFor(m);
|
|
@@ -1444,6 +1476,14 @@ function persist(s) {
|
|
|
1444
1476
|
// fact: a record persisted before profiles existed restores as "not recorded", and inventing a
|
|
1445
1477
|
// key for the past would be a lie about it.
|
|
1446
1478
|
if ('profile' in s) meta.profile = s.profile == null ? null : s.profile;
|
|
1479
|
+
// WHAT THIS SESSION IS FOR, and where that came from. Same `in`-guard as `profile` above and for
|
|
1480
|
+
// the same reason: every session running before this field existed has no attribution, and
|
|
1481
|
+
// writing a key for it would turn "we were never told" into "it belongs to nothing". Both keys
|
|
1482
|
+
// move together — an attribution with no provenance could not be told apart from a reading.
|
|
1483
|
+
if ('initiative' in s) {
|
|
1484
|
+
meta.initiative = s.initiative == null ? null : s.initiative;
|
|
1485
|
+
meta.attributedBy = s.attributedBy || null;
|
|
1486
|
+
}
|
|
1447
1487
|
try {
|
|
1448
1488
|
fs.writeFileSync(path.join(SESS_DIR, s.id + '.json'), JSON.stringify(meta));
|
|
1449
1489
|
fs.writeFileSync(path.join(SESS_DIR, s.id + '.scrollback'), s.buf);
|
|
@@ -1741,6 +1781,16 @@ function sessionList() {
|
|
|
1741
1781
|
? treeShare.roleOf(s.id, treeOfWs.get(s.ws) || null, writerByTree.get(treeOfWs.get(s.ws)))
|
|
1742
1782
|
: null,
|
|
1743
1783
|
tree_peers: treeShare.coTenants(rowsAll, treeOfWs, treeOfWs.get(s.ws) || null, s.id).length,
|
|
1784
|
+
// ── what this session is for (workspace-session-attribution R1) ──
|
|
1785
|
+
// THREE STATES, kept apart on the wire because the surface has to tell them apart:
|
|
1786
|
+
// initiative: 'x', attributed_by: 'spawn' PMOS was told at spawn — first-hand
|
|
1787
|
+
// initiative: 'x', attributed_by: 'declared' the PM bound a running session — their word
|
|
1788
|
+
// initiative: null, attributed_by: null never recorded — NOT "belongs to nothing"
|
|
1789
|
+
// The branch reading is deliberately NOT folded in here. It is the client's fallback
|
|
1790
|
+
// (lib/working-set.js), computed from `/api/workspaces`, and merging it into this field would
|
|
1791
|
+
// make an inference indistinguishable from a record — the exact confusion this field removes.
|
|
1792
|
+
initiative: 'initiative' in s ? (s.initiative == null ? null : s.initiative) : null,
|
|
1793
|
+
attributed_by: 'initiative' in s ? (s.attributedBy || null) : null,
|
|
1744
1794
|
profile_id: 'profile' in s ? (s.profile == null ? null : s.profile) : null,
|
|
1745
1795
|
profile_line: profilesLib.profileLine(
|
|
1746
1796
|
!('profile' in s) ? undefined
|
|
@@ -2101,6 +2151,24 @@ function nextSessionId() {
|
|
|
2101
2151
|
return id;
|
|
2102
2152
|
}
|
|
2103
2153
|
|
|
2154
|
+
/**
|
|
2155
|
+
* An initiative id, or null — the ONE gate every attribution path goes through.
|
|
2156
|
+
*
|
|
2157
|
+
* `fourPanel.validId` is the repo's existing answer to "is this a record name?" and it is reused
|
|
2158
|
+
* rather than re-typed: it already guards the `/api/initiative` path interpolation and the two
|
|
2159
|
+
* declaration stores, so a second regex here would be a second opinion about what an id is, free to
|
|
2160
|
+
* drift from the one that decides whether a file can be read.
|
|
2161
|
+
*
|
|
2162
|
+
* Returning null rather than throwing is deliberate. An unusable value must leave the session
|
|
2163
|
+
* UNATTRIBUTED — which is a real, honest state with a surface of its own — never half-attributed to
|
|
2164
|
+
* something that failed to parse, and never a spawn refused over a label.
|
|
2165
|
+
*/
|
|
2166
|
+
function initiativeIdOrNull(v) {
|
|
2167
|
+
if (v == null) return null;
|
|
2168
|
+
const s = String(v).trim();
|
|
2169
|
+
return s && fourPanel.validId(s) ? s : null;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2104
2172
|
// Resolve a requested profile id BEFORE anything is spawned (profiles R3/R9). The rule the whole
|
|
2105
2173
|
// feature turns on: a session must never claim a profile whose overlay was not applied. So the
|
|
2106
2174
|
// three outcomes are kept apart — nothing asked for, asked for and readable, asked for and gone —
|
|
@@ -2118,7 +2186,7 @@ function resolveProfileForSpawn(id) {
|
|
|
2118
2186
|
: `profile "${id}" is not among the descriptors in ${read.dir} — PMOS will not substitute another` };
|
|
2119
2187
|
}
|
|
2120
2188
|
|
|
2121
|
-
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
|
|
2189
|
+
function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile, initiative }) {
|
|
2122
2190
|
const id = nextSessionId();
|
|
2123
2191
|
const w = getWs(ws);
|
|
2124
2192
|
const root = w ? w.root : SELF_ROOT;
|
|
@@ -2140,6 +2208,24 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
|
|
|
2140
2208
|
const base = { id, ws: w ? w.id : wsId(SELF_ROOT), root, name: (name && String(name).trim()) || agent, agent, origin: tag,
|
|
2141
2209
|
profile: pres.id, profileRefusal: pres.ok ? null : pres.error };
|
|
2142
2210
|
|
|
2211
|
+
/* WHAT THIS SESSION IS FOR (workspace-session-attribution R1).
|
|
2212
|
+
*
|
|
2213
|
+
* A PMOS session has never recorded which initiative it is working on — not here, not on the wire,
|
|
2214
|
+
* not on disk — so every initiative-keyed surface had to INFER it from the `initiative/<id>` branch
|
|
2215
|
+
* convention, which holds in one of four ordinary situations. The board that shipped on that
|
|
2216
|
+
* inference could not see the session that wrote it, because that session was on a `claude/*`
|
|
2217
|
+
* branch.
|
|
2218
|
+
*
|
|
2219
|
+
* The key is set ONLY when the caller supplied one, exactly as `profile` is, and for the same
|
|
2220
|
+
* reason: its ABSENCE has to stay a fact. A session started before this field existed, or started
|
|
2221
|
+
* outside PMOS, restores as not-recorded — and "not recorded" must never be confused with
|
|
2222
|
+
* "recorded as none", which is what a `null` default would have produced. `attributedBy` says
|
|
2223
|
+
* where the value came from, because a value the PM declared and a value PMOS was told at spawn
|
|
2224
|
+
* are different claims and the surface has to be able to tell them apart.
|
|
2225
|
+
*/
|
|
2226
|
+
const init = initiativeIdOrNull(initiative);
|
|
2227
|
+
if (init) { base.initiative = init; base.attributedBy = 'spawn'; }
|
|
2228
|
+
|
|
2143
2229
|
// A missing agent CLI is detected BEFORE any spawn (on Linux the exec-in-fork failure is
|
|
2144
2230
|
// otherwise silent) — the same honest exited pane on both the tmux and the direct path.
|
|
2145
2231
|
if (!isShell && !commandExists(agent)) {
|
|
@@ -2215,7 +2301,7 @@ function spawnSession({ name, agent, cwd, ws, origin, initialInput, profile }) {
|
|
|
2215
2301
|
// A FAILED git operation in a real repo must never spawn a session on the wrong branch (rubric
|
|
2216
2302
|
// hard floor): we return {ok:false} and spawn nothing. Kept as its own function so the
|
|
2217
2303
|
// git-then-maybe-spawn contract is unit-testable without a WS client.
|
|
2218
|
-
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile }) {
|
|
2304
|
+
function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate, profile, initiative }) {
|
|
2219
2305
|
const w = getWs(ws);
|
|
2220
2306
|
if (!w) return { ok: false, error: 'no workspace' };
|
|
2221
2307
|
// The profile is checked BEFORE any git operation. A composer that picked a profile PMOS cannot
|
|
@@ -2257,7 +2343,7 @@ function spawnWithBranch({ name, agent, cwd, ws, branch, createBranch, isolate,
|
|
|
2257
2343
|
broadcast({ t: 'branch', ws: w.id, ...gitInfo.branchInfo(w.root) }); // let every client refresh the chip
|
|
2258
2344
|
}
|
|
2259
2345
|
}
|
|
2260
|
-
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id });
|
|
2346
|
+
const s = spawnSession({ name, agent: agent || 'shell', cwd, ws: targetWs, profile: pres.id, initiative });
|
|
2261
2347
|
// The co-tenancy fact, on the ordinary path too. Note where this sits: OUTSIDE the `if (branch)`
|
|
2262
2348
|
// block above. The isolate default already computes that other sessions would be disturbed — and
|
|
2263
2349
|
// then the whole git block is skipped when no branch was picked, which is the common path and the
|
|
@@ -2345,6 +2431,10 @@ function startWork({ initiative, repoKey, ws, agent, name, profile }) {
|
|
|
2345
2431
|
createBranch: plan.createBranch,
|
|
2346
2432
|
isolate: plan.isolate,
|
|
2347
2433
|
profile,
|
|
2434
|
+
// THE FIX THIS FUNCTION HAS BEEN ONE ARGUMENT AWAY FROM SINCE IT WAS WRITTEN. `initiative`
|
|
2435
|
+
// arrived here, decided the branch and supplied a fallback display name, and was then dropped
|
|
2436
|
+
// — so the one start path DESIGNED to know what it was starting work on never told the session.
|
|
2437
|
+
initiative,
|
|
2348
2438
|
});
|
|
2349
2439
|
if (!r.ok) return r;
|
|
2350
2440
|
return { ok: true, session: r.session, notice: r.notice || plan.why, plan: plan.plan };
|
|
@@ -3952,6 +4042,7 @@ async function handleRequest(req, res) {
|
|
|
3952
4042
|
: null,
|
|
3953
4043
|
artifactLinks: fourPanel.readDeclarations(stateDir, fourPanel.ARTIFACT_LINKS),
|
|
3954
4044
|
initiativeLinks: fourPanel.readDeclarations(stateDir, fourPanel.INITIATIVE_LINKS),
|
|
4045
|
+
artifactSync: fourPanel.readDeclarations(stateDir, fourPanel.ARTIFACT_SYNC),
|
|
3955
4046
|
// Which destinations a session could look up, derived from the PM's agent profiles. The home
|
|
3956
4047
|
// directory is passed IN rather than read inside four-panel.js for the same reason
|
|
3957
4048
|
// readProfiles takes it as a parameter: a module that reads os.homedir() itself can only be
|
|
@@ -3961,6 +4052,45 @@ async function handleRequest(req, res) {
|
|
|
3961
4052
|
// Discriminated READ results stay 200 — the body carries the reason, the transport code cannot.
|
|
3962
4053
|
return json(res, { ws: w ? w.id : null, ...out });
|
|
3963
4054
|
}
|
|
4055
|
+
if (p === '/api/session/initiative' && req.method === 'POST') {
|
|
4056
|
+
/* Bind a RUNNING session to an initiative — the PM's own word about work already in flight
|
|
4057
|
+
* (workspace-session-attribution R3/R8).
|
|
4058
|
+
*
|
|
4059
|
+
* Every session running when this shipped has no attribution, and so does every session started
|
|
4060
|
+
* outside PMOS. Without this they would stay unattributed forever, which would make the whole
|
|
4061
|
+
* field a promise about the future and no help with the work on screen today.
|
|
4062
|
+
*
|
|
4063
|
+
* Three properties this endpoint holds:
|
|
4064
|
+
* 1. It is a DECLARATION. `attributedBy: 'declared'` is written, never 'spawn', so the surface
|
|
4065
|
+
* can keep the PM's word apart from what PMOS was told first-hand. Same distinction
|
|
4066
|
+
* `initiative-links.jsonl` already draws with `origin`/`state`.
|
|
4067
|
+
* 2. It is SINGLE-VALUED, and a replacement says what it replaced. `previous` comes back on
|
|
4068
|
+
* the response rather than being overwritten with no trace (R8) — a re-bind the PM did not
|
|
4069
|
+
* mean to do is otherwise unrecoverable and unnoticeable.
|
|
4070
|
+
* 3. It touches NOTHING else. No checkout, no branch, no respawn. The session keeps running
|
|
4071
|
+
* exactly as it was; the only thing that changes is what PMOS says it is for.
|
|
4072
|
+
*
|
|
4073
|
+
* `initiative: null` CLEARS the binding, which is the honest way back from a wrong one — and it
|
|
4074
|
+
* still writes the key, because a session the PM has explicitly unbound is a different state
|
|
4075
|
+
* from one that was never recorded.
|
|
4076
|
+
*/
|
|
4077
|
+
let body = {};
|
|
4078
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
4079
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
4080
|
+
const s = sessions.get(String(body.id || ''));
|
|
4081
|
+
if (!s) return json(res, { ok: false, error: 'no such session' }, 404);
|
|
4082
|
+
const clearing = body.initiative === null || body.initiative === '';
|
|
4083
|
+
const next = clearing ? null : initiativeIdOrNull(body.initiative);
|
|
4084
|
+
if (!clearing && !next) {
|
|
4085
|
+
return json(res, { ok: false, error: 'that initiative id is not a record name' }, 400);
|
|
4086
|
+
}
|
|
4087
|
+
const previous = 'initiative' in s ? (s.initiative || null) : null;
|
|
4088
|
+
s.initiative = next;
|
|
4089
|
+
s.attributedBy = next ? 'declared' : null;
|
|
4090
|
+
persist(s);
|
|
4091
|
+
pushSessions();
|
|
4092
|
+
return json(res, { ok: true, id: s.id, initiative: next, attributed_by: s.attributedBy, previous });
|
|
4093
|
+
}
|
|
3964
4094
|
if (p === '/api/initiative/link' && req.method === 'POST') {
|
|
3965
4095
|
// Recording that an artifact corresponds to an external page. This writes a DECLARATION and
|
|
3966
4096
|
// makes no request: PMOS holds no credential, so it can state what the PM says the mapping is
|
|
@@ -3986,6 +4116,36 @@ async function handleRequest(req, res) {
|
|
|
3986
4116
|
// A write that failed returns 500 and says so — never a false green (the /api/gate rule).
|
|
3987
4117
|
return json(res, { ...wrote, connectors: fourPanel.connectors(os.homedir()) }, wrote.ok ? 200 : 500);
|
|
3988
4118
|
}
|
|
4119
|
+
if (p === '/api/initiative/sync' && req.method === 'POST') {
|
|
4120
|
+
// The PM chose a direction for a link they already recorded, or a session is reporting what it
|
|
4121
|
+
// did about one. Both arrive here, and the difference between them is exactly the difference the
|
|
4122
|
+
// store enforces: a direction the PM chose is `proposed` and claims nothing, while a session's
|
|
4123
|
+
// report is `applied` and is REFUSED unless it names the profile that performed it.
|
|
4124
|
+
//
|
|
4125
|
+
// This route composes the task text and appends the row. It performs no sync — there is no
|
|
4126
|
+
// branch here that contacts Confluence, Jira or Google, and there cannot be one: the workspace
|
|
4127
|
+
// holds no credential (D53), and `no-network.test.js` asserts the absence structurally.
|
|
4128
|
+
const w = getWs(url.searchParams.get('ws'));
|
|
4129
|
+
let body = {};
|
|
4130
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); }
|
|
4131
|
+
catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
|
|
4132
|
+
const w2 = body.ws ? getWs(body.ws) : w;
|
|
4133
|
+
const built = fourPanel.syncRow(body);
|
|
4134
|
+
if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
|
|
4135
|
+
const task = fourPanel.syncTask({
|
|
4136
|
+
direction: built.row.direction, kind: built.row.kind,
|
|
4137
|
+
artifact_path: built.row.artifact_path, target: built.row.target, profile: body.profile || null,
|
|
4138
|
+
});
|
|
4139
|
+
const engine = consoleState.loadEngine();
|
|
4140
|
+
if (!engine.ok) return json(res, { ok: false, error: engine.error }, 500);
|
|
4141
|
+
const stateDir = fourPanel.storeDir(engine.engine, w2 ? w2.root : SELF_ROOT);
|
|
4142
|
+
const wrote = fourPanel.appendDeclaration(stateDir, fourPanel.ARTIFACT_SYNC, built.row);
|
|
4143
|
+
// A write that failed returns 500 and says so — never a false green (the /api/gate rule). The
|
|
4144
|
+
// task is returned either way: a PM whose store is unwritable can still run the work by hand,
|
|
4145
|
+
// and withholding the text would turn a recording failure into a capability failure.
|
|
4146
|
+
return json(res, { ...wrote, task: task.ok ? task.text : null, task_error: task.ok ? null : task.error },
|
|
4147
|
+
wrote.ok ? 200 : 500);
|
|
4148
|
+
}
|
|
3989
4149
|
if (p === '/api/initiative/lookup-task' && req.method === 'POST') {
|
|
3990
4150
|
// The task a SESSION runs to resolve a reference. This endpoint composes a string and returns
|
|
3991
4151
|
// it. It contacts nothing — that is the entire point of the design: PMOS describes the lookup,
|
|
@@ -4459,7 +4619,10 @@ wss.on('connection', (ws) => {
|
|
|
4459
4619
|
} else if (m.t === 'resize') {
|
|
4460
4620
|
const s = sessions.get(m.id); if (s && s.pty) { try { s.pty.resize(m.cols, m.rows); s.cols = m.cols; s.rows = m.rows; } catch {} }
|
|
4461
4621
|
} else if (m.t === 'new') {
|
|
4462
|
-
|
|
4622
|
+
// `initiative` is what the client has always held and never sent: `+ session` is clicked on
|
|
4623
|
+
// an initiative row, the tab carries its `slug`, and the spawn message named three other
|
|
4624
|
+
// fields. An absent or unusable value leaves the session unattributed — never a refused spawn.
|
|
4625
|
+
const r = spawnWithBranch({ name: m.name, agent: m.agent, cwd: m.cwd, ws: m.ws, branch: m.branch, createBranch: m.createBranch, isolate: m.isolate, profile: m.profile, initiative: m.initiative });
|
|
4463
4626
|
// A refused profile is its own sentence, not "branch: …" — the composer's error line names
|
|
4464
4627
|
// what PMOS could not read, and nothing was started.
|
|
4465
4628
|
if (!r.ok) { ws.send(JSON.stringify({ t: 'error', error: (/^profile /.test(r.error || '') ? '' : 'branch: ') + r.error })); return; }
|