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.
- package/README.md +28 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/workspace/README.md +10 -0
- package/workspace/lib/four-panel.js +201 -6
- package/workspace/lib/session-state.js +56 -2
- package/workspace/lib/working-set.js +224 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/index.html +7 -0
- package/workspace/public/pm/data.js +100 -16
- package/workspace/public/pm/panel-detail.js +327 -93
- package/workspace/public/pm/panel-initiatives.js +172 -17
- package/workspace/public/pm/state.js +6 -0
- package/workspace/server.js +69 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
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);
|
|
@@ -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,
|