create-pmos 0.23.0 → 0.25.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.
@@ -25,6 +25,33 @@
25
25
  * overrides the data layer may publish into state — `state.board` ({repoId: group[]}) and
26
26
  * `state.records` ({slug: record}). Nothing here caches anything derivable (D33): groups, counts,
27
27
  * search hits and the selected row are computed at read time, every render.
28
+ *
29
+ * ── What the board says about YOUR work (workspace-active-visibility) ────────────────────────
30
+ * On 178 records the filing order is the right filing order and the wrong ATTENTION order, and the
31
+ * PM could not tell which initiatives they were working on or which had a live agent. Two additions,
32
+ * both inside the elements that were already here:
33
+ *
34
+ * 1. THE ROW'S LEADING SLOT. It held a constant `#` — the Slack channel glyph, ported for its
35
+ * look, carrying nothing on every row. On an initiative with a live session it now holds that
36
+ * session's glyph, in that session's colour, with every session named in the tooltip. Same
37
+ * span, same node count, same geometry. The TRAILING dot is untouched: it is the ladder's, and
38
+ * `lightRecord` cannot derive 178 of those without ~30s of `git log`.
39
+ *
40
+ * 2. A DERIVED GROUP ABOVE THE OBJECTIVES. Built from `groupNode`, the same element, with the same
41
+ * collapse key — derived groups are not new here (`unanchored` and `no record` are both
42
+ * constructed in data.js). It lists what a live session, a checked-out working tree, or an open
43
+ * tab says the PM is on, ordered so the initiative WAITING on them is the first row.
44
+ *
45
+ * Three rules hold it honest. Rows are DUPLICATED, never moved — an initiative keeps its place under
46
+ * its own anchor, because the anchor is the truth (D59) and a shortcut that re-homes work is a second
47
+ * filing system. The group is ABSENT, not empty, when nothing is live. And the join is
48
+ * lib/working-set.js's, which is the server's own `initiative/<id>` two-hopper — so a session the
49
+ * join cannot reach contributes nothing rather than being bucketed somewhere plausible.
50
+ *
51
+ * Fixture mode is structurally unaffected, not conditionally suppressed: `pollSessions` returns early
52
+ * on FIXTURE and `foldWorkspaces` never runs, so `state.sessions` is empty and there are no branches.
53
+ * There is no server in fixture mode, so there are no live sessions to claim — the same rule line 45
54
+ * states in the other direction.
28
55
  */
29
56
 
30
57
  import { el, setChildren } from './dom.js';
@@ -103,6 +130,75 @@ function sessionsOf(slug, DATA) {
103
130
  return [...(i.sessions || []), ...((state.extraSess && state.extraSess[slug]) || [])];
104
131
  }
105
132
 
133
+ /* ── the working set: which initiatives are yours, and which of them are live ─────────────────── */
134
+
135
+ /** The shared modules, off the globals the way panel-tabs.js reads window.PMOSTreeShare. */
136
+ const WS = () => (typeof window !== 'undefined' ? window.PMOSWorkingSet : null);
137
+ const SS = () => (typeof window !== 'undefined' ? window.PMOSSessionState : null);
138
+
139
+ /** workspace id → branch, published by the data layer alongside `board` and `productWs`. */
140
+ const branches = () => (state.wsBranches && typeof state.wsBranches === 'object' ? state.wsBranches : {});
141
+
142
+ /**
143
+ * Every LIVE session, keyed by the initiative its working tree is on: `{slug: [session…]}`.
144
+ *
145
+ * Fixture mode returns nothing and SAYS so rather than relying on the inputs happening to be empty.
146
+ * `pollSessions` does return early on FIXTURE — but a marker drawn from seed data is "the beautiful
147
+ * shell" in the PRD's own failure-mode list, and that is not a property to leave resting on a
148
+ * `return` two modules away.
149
+ */
150
+ function liveSessions() {
151
+ if (FIXTURE) return {};
152
+ const ws = WS();
153
+ if (!ws) return {};
154
+ const map = branches();
155
+ return ws.sessionsByInitiative(state.sessions, (id) => map[id] || null);
156
+ }
157
+
158
+ /**
159
+ * The rows the derived group shows, in attention order.
160
+ *
161
+ * `onBoard` is what stops a tab or a working tree belonging to ANOTHER repository putting a row on
162
+ * this repository's board — `slugProduct()` is the same map panel 4 scopes its tabs by.
163
+ */
164
+ function workingSet(live, product) {
165
+ if (FIXTURE) return [];
166
+ const ws = WS();
167
+ if (!ws) return [];
168
+ const owner = slugProduct();
169
+ return ws.workingSet({
170
+ live,
171
+ trees: ws.initiativesWithTree(Object.values(branches())),
172
+ tabs: ws.initiativesWithTab(state.tabs),
173
+ onBoard: (slug) => owner[slug] === product,
174
+ });
175
+ }
176
+
177
+ /**
178
+ * The row's leading slot, for one initiative: `['#', colour, title]` when nothing is live, and the
179
+ * session's own glyph/colour/tooltip when something is.
180
+ *
181
+ * The vocabulary is `window.PMOSSessionState`'s — the module index.html loads for exactly this
182
+ * reason ("ONE table, TWO consumers"). Not `FX.SDOT`, which is the design prototype's seed data,
183
+ * parity-locked to prototype-original.html, and which gives `working` and `needs` the same ● and
184
+ * separates them by hue alone. On a board the PM scans, a signal that dies in greyscale is not a
185
+ * signal.
186
+ */
187
+ const HASH = ['#', 'var(--muted-foreground)', ''];
188
+
189
+ function leadOf(slug, live) {
190
+ const rows = live[slug];
191
+ if (!rows || !rows.length) return HASH;
192
+ const ss = SS();
193
+ const ws = WS();
194
+ if (!ss || !ws) return HASH;
195
+ const st = ws.stateOf(rows);
196
+ // A state the table does not know is NOT drawn. Falling back to a generic dot here would put a
197
+ // mark on the board that no vocabulary can explain, which is the one thing worse than no mark.
198
+ if (!st || !ss.GLYPH[st]) return HASH;
199
+ return [ss.GLYPH[st], ss.COLOR[st] || 'var(--muted-foreground)', ws.sessionTitle(slug, rows)];
200
+ }
201
+
106
202
  /** Which repository owns each slug (lines 1963–1965) — the tab-scoping key. */
107
203
  function slugProduct() {
108
204
  const map = {};
@@ -221,8 +317,14 @@ function groupNode(g, product) {
221
317
  onClick: () => setState({ sel: it.slug, detail: { kind: 'init' }, col2: true }),
222
318
  style: 'display:flex;align-items:center;gap:7px;width:100%;padding:5px 12px 5px 27px;min-height:32px;border-left:2px solid ' + it.bl + ';background:' + it.bg,
223
319
  hover: 'background:var(--accent)',
320
+ // Stated where the PM is standing, not only in a tooltip on the glyph: on a working-set row
321
+ // this names WHICH of the three signals put it there. The glyph summarises; this does not.
322
+ title: it.why || null,
224
323
  },
225
- el('span', { style: 'flex:none;font-size:12px;color:var(--muted-foreground)' }, '#'),
324
+ // ① The slot that used to be a constant. `#` when nothing is live — byte-identical to what
325
+ // shipped, which is what keeps the comparator's text axis clean — and the session's own
326
+ // glyph when something is. One span either way: no node added, no geometry moved.
327
+ el('span', { style: 'flex:none;font-size:12px;color:' + it.leadC, title: it.leadT || null }, it.lead),
226
328
  el('span', { style: 'font-family:var(--mono);font-size:12.5px;color:var(--foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(it.slug)),
227
329
  el('span', { style: 'margin-left:auto;flex:none;font-size:9px;color:' + it.l2c, title: it.l2b }, '●'),
228
330
  ));
@@ -238,22 +340,44 @@ function groupNode(g, product) {
238
340
 
239
341
  /* ── "matches elsewhere" — artifacts and sessions that the group list cannot show ──────────── */
240
342
 
241
- function searchHits(q, DATA) {
343
+ /**
344
+ * `searchHits` used to read sessions off `record.sessions` ONLY — and board records are LIGHT, with
345
+ * `sessions: []`, until the PM opens one. So a search for a session's name returned nothing for 177
346
+ * of 178 initiatives, silently, with no indication that the search had not looked. The hit was
347
+ * scoped to hydrated records and nothing said so.
348
+ *
349
+ * `live` closes it: the same join the row markers use, over every session the server has published,
350
+ * regardless of what has been hydrated. The old path is KEPT rather than replaced — it also carries
351
+ * `state.extraSess`, the sessions this window started, which have no server row until the next poll.
352
+ * `seen` is what stops one session arriving twice when both paths find it.
353
+ */
354
+ function searchHits(q, DATA, live) {
242
355
  const hits = [];
243
356
  if (!q) return hits;
357
+ const seen = new Set();
358
+ const push = (slug, s) => {
359
+ const key = slug + '|' + (s.id || s.name);
360
+ if (seen.has(key)) return;
361
+ seen.add(key);
362
+ hits.push({
363
+ kind: 'session · ' + slug,
364
+ label: s.name + ' · ' + (s.label || s.shown || s.st || ''),
365
+ on: () => { setState({ sel: slug, detail: { kind: 'init' } }); openTerm(slug, s); },
366
+ });
367
+ };
244
368
  Object.keys(DATA).forEach((slug) => {
245
369
  (DATA[slug].arts || []).forEach((a) => {
246
370
  if (a.path.toLowerCase().includes(q)) {
247
371
  hits.push({ kind: 'artifact · ' + a.role, label: a.path, on: () => openDoc(slug, a.path, a.role) });
248
372
  }
249
373
  });
250
- sessionsOf(slug, DATA).forEach((s) => {
251
- if (s.name.toLowerCase().includes(q)) {
252
- hits.push({
253
- kind: 'session · ' + slug, label: s.name + ' · ' + s.label,
254
- on: () => { setState({ sel: slug, detail: { kind: 'init' } }); openTerm(slug, s); },
255
- });
256
- }
374
+ sessionsOf(slug, DATA).forEach((s) => { if (s.name.toLowerCase().includes(q)) push(slug, s); });
375
+ });
376
+ Object.keys(live || {}).forEach((slug) => {
377
+ (live[slug] || []).forEach((s) => {
378
+ // The server's row calls the presented state `shown`; a record's row calls it `st`. `openTerm`
379
+ // reads `st`, so the shape is normalised HERE rather than teaching the opener a second one.
380
+ if (s.name && s.name.toLowerCase().includes(q)) push(slug, { ...s, st: s.shown });
257
381
  });
258
382
  });
259
383
  return hits.slice(0, 8);
@@ -398,23 +522,54 @@ export function renderInitiatives(host) {
398
522
  const DATA = records();
399
523
  const selSlug = selectedSlug();
400
524
  const detKind = (state.detail && state.detail.kind) || '';
525
+ // ONE join per render, shared by the row markers, the working-set group and the search. Computing
526
+ // it three times would be three chances to disagree about one fact.
527
+ const live = liveSessions();
528
+
529
+ /** One row's view model. `why` is null everywhere except in the working-set group. */
530
+ const rowOf = (slug, why) => {
531
+ const i = DATA[slug];
532
+ // The prototype reads `i.l2` unguarded; a real repository can hand back a slug with no
533
+ // record at all, and a board that throws is worse than a row that reads "no record".
534
+ const l2 = (i && i.l2) || ['', '', FX.C.mut];
535
+ const [lead, leadC, leadT] = leadOf(slug, live);
536
+ return {
537
+ slug, ...selRow(slug === selSlug && detKind === 'init'),
538
+ l2a: l2[0], l2b: l2[1], l2c: l2[2], lead, leadC, leadT, why: why || null,
539
+ };
540
+ };
401
541
 
402
542
  let total = 0;
403
543
  const groups = groupsFor(product).map((g) => {
404
- const inits = g.slugs.filter((s) => !q || s.includes(q)).map((slug) => {
405
- const i = DATA[slug];
406
- // The prototype reads `i.l2` unguarded; a real repository can hand back a slug with no
407
- // record at all, and a board that throws is worse than a row that reads "no record".
408
- const l2 = (i && i.l2) || ['', '', FX.C.mut];
409
- return { slug, ...selRow(slug === selSlug && detKind === 'init'), l2a: l2[0], l2b: l2[1], l2c: l2[2] };
410
- });
544
+ const inits = g.slugs.filter((s) => !q || s.includes(q)).map((slug) => rowOf(slug, null));
411
545
  total += inits.length;
412
546
  return { ...g, inits, isEmpty: inits.length === 0 };
413
547
  }).filter((g) => g.inits.length > 0 || (g.dyn && !q));
414
548
 
415
- const hits = searchHits(q, DATA);
549
+ const hits = searchHits(q, DATA, live);
416
550
 
417
551
  const kids = [header, searchBox];
552
+ /* THE WORKING SET, pinned above the Objectives.
553
+ *
554
+ * It is `groupNode` — the same element, the same collapse key, the same forced-open-on-search
555
+ * rule — and it is NOT in `groups`, deliberately: it must not be counted in `total`, because
556
+ * `total` decides the "nothing matches" line and a duplicate row is not a second match.
557
+ *
558
+ * `hasKr` is reused to state the derivation. That slot renders an id and a sentence under a group
559
+ * header, which is exactly the shape "what is this group?" needs, and reusing it is what the PM
560
+ * asked for ("use the existing one"). It carries no KR here and claims none.
561
+ *
562
+ * Absent when empty. Not a "0", not a placeholder: a heading over nothing is a heading the PM
563
+ * learns to skip. */
564
+ const now = workingSet(live, product).filter((e) => !q || e.slug.includes(q));
565
+ if (now.length) {
566
+ kids.push(groupNode({
567
+ gid: 'NOW', dyn: true, idc: FX.C.brand, gname: 'working on', gct: String(now.length),
568
+ hasKr: true, krid: '⑂', krname: 'live sessions · checked-out trees · open tabs',
569
+ inits: now.map((e) => rowOf(e.slug, WS() ? WS().reasonTitle(e) : null)),
570
+ isEmpty: false,
571
+ }, product));
572
+ }
418
573
  for (const g of groups) kids.push(groupNode(g, product));
419
574
  kids.push(fileNewRow);
420
575
  if (hits.length) {
@@ -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
@@ -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'], ['-user-stories', 'user stories'], ['-copy', 'copy'],
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 null;
904
+ return prefixOwner(m.stem);
873
905
  };
874
906
  for (const m of metas) {
875
907
  const k = keyFor(m);
@@ -3243,6 +3275,10 @@ async function handleRequest(req, res) {
3243
3275
  if (p === '/lib/mobile-nav.js') return serveFile(res, path.join(__dirname, 'lib', 'mobile-nav.js'));
3244
3276
  if (p === '/lib/session-state.js') return serveFile(res, path.join(__dirname, 'lib', 'session-state.js'));
3245
3277
  if (p === '/lib/tree-share.js') return serveFile(res, path.join(__dirname, 'lib', 'tree-share.js'));
3278
+ // Which initiatives the PM is working on, and which of those are live. The session→initiative join
3279
+ // is the same two-hopper lib/four-panel.js makes per initiative on the server; the board makes it
3280
+ // once over every session, from this module, so 178 rows cost no requests.
3281
+ if (p === '/lib/working-set.js') return serveFile(res, path.join(__dirname, 'lib', 'working-set.js'));
3246
3282
  // The editor computes its live diff with the SAME module the server saves through, so what the PM
3247
3283
  // sees while typing and what gets written cannot disagree. NB: /lib/ is an explicit allowlist, not
3248
3284
  // a directory route — a <script src> for a file missing from this list 404s silently, and the
@@ -3948,6 +3984,7 @@ async function handleRequest(req, res) {
3948
3984
  : null,
3949
3985
  artifactLinks: fourPanel.readDeclarations(stateDir, fourPanel.ARTIFACT_LINKS),
3950
3986
  initiativeLinks: fourPanel.readDeclarations(stateDir, fourPanel.INITIATIVE_LINKS),
3987
+ artifactSync: fourPanel.readDeclarations(stateDir, fourPanel.ARTIFACT_SYNC),
3951
3988
  // Which destinations a session could look up, derived from the PM's agent profiles. The home
3952
3989
  // directory is passed IN rather than read inside four-panel.js for the same reason
3953
3990
  // readProfiles takes it as a parameter: a module that reads os.homedir() itself can only be
@@ -3982,6 +4019,36 @@ async function handleRequest(req, res) {
3982
4019
  // A write that failed returns 500 and says so — never a false green (the /api/gate rule).
3983
4020
  return json(res, { ...wrote, connectors: fourPanel.connectors(os.homedir()) }, wrote.ok ? 200 : 500);
3984
4021
  }
4022
+ if (p === '/api/initiative/sync' && req.method === 'POST') {
4023
+ // The PM chose a direction for a link they already recorded, or a session is reporting what it
4024
+ // did about one. Both arrive here, and the difference between them is exactly the difference the
4025
+ // store enforces: a direction the PM chose is `proposed` and claims nothing, while a session's
4026
+ // report is `applied` and is REFUSED unless it names the profile that performed it.
4027
+ //
4028
+ // This route composes the task text and appends the row. It performs no sync — there is no
4029
+ // branch here that contacts Confluence, Jira or Google, and there cannot be one: the workspace
4030
+ // holds no credential (D53), and `no-network.test.js` asserts the absence structurally.
4031
+ const w = getWs(url.searchParams.get('ws'));
4032
+ let body = {};
4033
+ try { body = JSON.parse((await readBody(req)) || '{}'); }
4034
+ catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
4035
+ const w2 = body.ws ? getWs(body.ws) : w;
4036
+ const built = fourPanel.syncRow(body);
4037
+ if (!built.ok) return json(res, { ok: false, error: built.error }, 400);
4038
+ const task = fourPanel.syncTask({
4039
+ direction: built.row.direction, kind: built.row.kind,
4040
+ artifact_path: built.row.artifact_path, target: built.row.target, profile: body.profile || null,
4041
+ });
4042
+ const engine = consoleState.loadEngine();
4043
+ if (!engine.ok) return json(res, { ok: false, error: engine.error }, 500);
4044
+ const stateDir = fourPanel.storeDir(engine.engine, w2 ? w2.root : SELF_ROOT);
4045
+ const wrote = fourPanel.appendDeclaration(stateDir, fourPanel.ARTIFACT_SYNC, built.row);
4046
+ // A write that failed returns 500 and says so — never a false green (the /api/gate rule). The
4047
+ // task is returned either way: a PM whose store is unwritable can still run the work by hand,
4048
+ // and withholding the text would turn a recording failure into a capability failure.
4049
+ return json(res, { ...wrote, task: task.ok ? task.text : null, task_error: task.ok ? null : task.error },
4050
+ wrote.ok ? 200 : 500);
4051
+ }
3985
4052
  if (p === '/api/initiative/lookup-task' && req.method === 'POST') {
3986
4053
  // The task a SESSION runs to resolve a reference. This endpoint composes a string and returns
3987
4054
  // it. It contacts nothing — that is the entire point of the design: PMOS describes the lookup,