create-pmos 0.67.1 → 0.69.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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/guide/command-palette.md +1 -1
  7. package/workspace/guide/initiatives-screen.md +1 -1
  8. package/workspace/guide/layout.md +35 -8
  9. package/workspace/guide/notes.md +12 -1
  10. package/workspace/guide/today.md +3 -1
  11. package/workspace/lib/notes-links.js +56 -0
  12. package/workspace/lib/notes.js +87 -5
  13. package/workspace/lib/routes-notes.js +47 -6
  14. package/workspace/package-lock.json +1 -1
  15. package/workspace/package.json +1 -1
  16. package/workspace/public/pm/components.css +697 -3
  17. package/workspace/public/pm/doc-editor.js +473 -0
  18. package/workspace/public/pm/doc-rail.js +161 -0
  19. package/workspace/public/pm/doc-remote.js +180 -0
  20. package/workspace/public/pm/dock-sessions.js +57 -13
  21. package/workspace/public/pm/frame-copy.js +2 -0
  22. package/workspace/public/pm/icons.js +1 -1
  23. package/workspace/public/pm/initiative-sheet.js +10 -2
  24. package/workspace/public/pm/initiatives-copy.js +1 -0
  25. package/workspace/public/pm/notes-links.js +110 -0
  26. package/workspace/public/pm/panel-tabs.js +85 -3
  27. package/workspace/public/pm/primitives.js +8 -4
  28. package/workspace/public/pm/state.js +3 -0
  29. package/workspace/public/pm/tab-doc.js +175 -170
  30. package/workspace/public/pm/today-agenda.js +6 -1
  31. package/workspace/public/pm/today-day.js +6 -0
  32. package/workspace/public/pm/view-notes.js +113 -6
  33. package/workspace/public/vendor/lucide-sprite.js +15 -2
  34. package/workspace/replaced-tests.json +43 -0
  35. package/workspace/server.js +30 -0
  36. package/workspace/test-lists/workspace-tabs-declutter.json +13 -0
  37. package/workspace/third-party/README.md +1 -1
@@ -1,4 +1,4 @@
1
- /* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1 to 3).
1
+ /* pm/view-notes.js — the Notes view tab: one meeting on one screen (meeting-notes, Runs 1 to 4).
2
2
  *
3
3
  * The screen follows the simplified mockup the PM reviewed (prototype/meeting-notes):
4
4
  * header the title and the date, and one action: Listen; while listening a red dot, the time, the sources and Stop;
@@ -24,10 +24,15 @@
24
24
  * the transcript line, its item and the note line written at that moment are highlighted, and a time in the transcript
25
25
  * jumps there. Your notes are shown read-only while the meeting plays, so their lines can be highlighted.
26
26
  *
27
+ * MEETING AND INITIATIVE (Run 4). Under the title, the note says which meeting it is (a calendar event, or not on the
28
+ * calendar) and which initiative it belongs to, and how that was set: from the calendar event, suggested from what was said,
29
+ * or chosen by the PM. Change opens a sheet for each. A note taken from an event's detail (pm/notes-links.js) opens here.
30
+ *
27
31
  * Reads and writes (lib/routes-notes.js). Notes live in the per-user runtime directory, never in a repository:
28
32
  * GET /api/notes · /api/notes/one?id=… · /api/notes/engine?ws=… · /api/notes/recording?id=… · /api/people?ws=…
33
+ * · /api/notes/initiatives?ws=…&id=… · /api/proactive/agenda?month=…
29
34
  * POST /api/notes/new · save · dictate · record · transcript · audio · label · speaker · switch · whisper-setup · private
30
- * · summarize · summarize-agent · delete · line · recording-delete
35
+ * · summarize · summarize-agent · delete · line · recording-delete · link
31
36
  */
32
37
  import { el } from './dom.js';
33
38
  import { askConfirm, askText } from './dialogs.js';
@@ -58,6 +63,7 @@ let pollTimer = null; let agentTimer = null;
58
63
  let busy = '';
59
64
  let player = null; // the replay: { id, bytes, el, playing }, kept across redraws so playback goes on
60
65
  let people = null; // { ws, names } — the repository's people, offered in the line editor
66
+ let pendingOpen = null; // a note another page asked to open (openNote), selected at the next draw
61
67
  const LABELS = [['decision', 'Decision'], ['action', 'Action'], ['question', 'Question'], ['fact', 'Fact'], ['topic', 'Topic'], ['statement', 'Statement'], ['small talk', 'Small talk']];
62
68
 
63
69
  const nowClock = (iso) => { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } };
@@ -199,7 +205,7 @@ async function loadList(ctx) {
199
205
  if (!currentId && list.notes.length) currentId = list.notes[0].id;
200
206
  ctx.setState({});
201
207
  }
202
- const sig = (n) => JSON.stringify([n.transcript, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title, (n.versions || []).length, n.__recording && n.__recording.bytes]);
208
+ const sig = (n) => JSON.stringify([n.transcript, n.summary && n.summary.at, n.agent && n.agent.markdown, n.private, n.speakers, n.title, (n.versions || []).length, n.__recording && n.__recording.bytes, n.meeting, n.initiative]);
203
209
  async function loadNote(ctx, id) {
204
210
  const r = await pmGet('/api/notes/one?id=' + encodeURIComponent(id));
205
211
  if (r && r.ok) {
@@ -312,9 +318,10 @@ async function setPrivate(ctx, n, priv) {
312
318
  async function summarize(ctx, n, quiet = false) {
313
319
  await flushSave(ctx);
314
320
  busy = 'summary'; ctx.setState({});
315
- const r = await pmPost('/api/notes/summarize', { id: n.id });
321
+ const ws = n.ws || wsOf(ctx);
322
+ const r = await pmPost('/api/notes/summarize', { id: n.id, ...(ws ? { ws } : {}) });
316
323
  busy = '';
317
- if (r && r.ok) { n.summary = r.summary; banner = null; } else if (!quiet) banner = { tone: 'bad', text: (r && r.error) || 'no summary was made' };
324
+ if (r && r.ok) { n.summary = r.summary; if (r.initiative !== undefined) { n.initiative = r.initiative; const c = cache.get(n.id); if (c) c.initiative = r.initiative; } banner = null; } else if (!quiet) banner = { tone: 'bad', text: (r && r.error) || 'no summary was made' };
318
325
  ctx.setState({});
319
326
  }
320
327
  async function summarizeAgent(ctx, n) {
@@ -762,6 +769,105 @@ function editorFor(ctx, n) {
762
769
  title.addEventListener('blur', () => flushSave(ctx));
763
770
  return editor;
764
771
  }
772
+ /* ── the meeting and the initiative of a note (Run 4) ───────────────────────────────────────────── */
773
+ const BY_WORDS = { event: 'from the calendar event', local: 'suggested from what was said', pm: 'chosen by you' };
774
+ const ymOfIso = (iso) => { const d = new Date(iso); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0'); };
775
+ const dateOfIso = (iso) => { const d = new Date(iso); return ymOfIso(iso) + '-' + String(d.getDate()).padStart(2, '0'); };
776
+ /** Open the Notes tab on this note at its next draw (pm/notes-links.js openNote). */
777
+ export function openNote(id) { pendingOpen = id; }
778
+ async function link(ctx, n, patch, said) {
779
+ const r = await pmPost('/api/notes/link', { id: n.id, ...patch });
780
+ if (r && r.ok) {
781
+ for (const x of new Set([n, cache.get(n.id)])) if (x) { x.meeting = r.note.meeting; x.initiative = r.note.initiative; x.title = r.note.title; }
782
+ if (editor && editor.id === n.id && !editor.title.value.trim()) editor.title.value = r.note.title || '';
783
+ touchRow(n); banner = { tone: 'ok', text: said(r.note) };
784
+ import('./notes-links.js').then((m) => m.refreshNotes()).catch(() => {});
785
+ } else banner = { tone: 'bad', text: (r && r.error) || 'the link was not changed' };
786
+ ctx.setState({});
787
+ }
788
+ /** The events of the note's day and the day before, from the Agenda's read. */
789
+ async function eventsNear(n) {
790
+ const at = n.created_at || new Date().toISOString();
791
+ const day = dateOfIso(at); const before = dateOfIso(new Date(Date.parse(at) - 86400000).toISOString());
792
+ const months = [...new Set([ymOfIso(at), before.slice(0, 7)])];
793
+ const reads = await Promise.all(months.map((m) => pmGet('/api/proactive/agenda?month=' + m)));
794
+ const out = []; const seen = new Set();
795
+ for (const r of reads) {
796
+ if (!r || !r.days) continue;
797
+ for (const d of [before, day]) for (const e of ((r.days[d] && r.days[d].events) || [])) { const k = e.start + '|' + e.title; if (!seen.has(k)) { seen.add(k); out.push({ ...e, date: d }); } }
798
+ }
799
+ return out.sort((a, b) => String(a.start).localeCompare(String(b.start)));
800
+ }
801
+ async function meetingSheet(ctx, n) {
802
+ const box = el('div', { class: 'nt-picks', role: 'radiogroup', 'aria-label': 'Meeting' }, el('p', { class: 'nt-hint' }, 'reading your calendar …'));
803
+ let pick = n.meeting && n.meeting.kind === 'adhoc' ? 'adhoc' : null;
804
+ let evs = [];
805
+ const opt = (key, title, sub, on) => button({ label: el('span', { class: 'nt-pick__txt' }, el('b', null, title), el('span', { class: 'nt-hint' }, sub)), variant: 'ghost', attrs: { class: 'nt-pick', role: 'radio', 'aria-checked': on ? 'true' : 'false', 'data-nt-meeting-pick': key },
806
+ onClick: (ev) => { pick = key; for (const b of box.querySelectorAll('[data-nt-meeting-pick]')) b.setAttribute('aria-checked', b === ev.currentTarget ? 'true' : 'false'); } });
807
+ const h = sheet({ title: 'Which meeting is this?', sub: 'The day of the note and the day before, from your calendar.', body: [box], attrs: { 'data-nt-meeting-sheet': '' },
808
+ actions: [button({ label: 'Cancel', variant: 'ghost', onClick: () => h.close() }), button({ label: 'Link', variant: 'primary', attrs: { 'data-nt-meeting-save': '' }, onClick: async () => {
809
+ if (!pick) { h.close(); return; }
810
+ h.close();
811
+ if (pick === 'adhoc') return link(ctx, n, { meeting: { kind: 'adhoc' } }, () => 'Marked as a meeting that is not on the calendar.');
812
+ const e = evs[Number(pick)];
813
+ const { meetingOf } = await import('./notes-links.js');
814
+ return link(ctx, n, { meeting: meetingOf(e) }, () => 'Linked to ' + e.title + '. The note shows on the calendar.');
815
+ } })] });
816
+ evs = await eventsNear(n);
817
+ if (!h.open) return;
818
+ const { notesFor } = await import('./notes-links.js');
819
+ box.replaceChildren(...evs.map((e, i) => {
820
+ const on = !!(n.meeting && n.meeting.kind === 'event' && (n.meeting.event_id === e.id || (Date.parse(n.meeting.start) === Date.parse(e.start) && n.meeting.title === e.title)));
821
+ if (on) pick = String(i);
822
+ const other = notesFor(e).some((x) => x.id !== n.id);
823
+ return opt(String(i), e.title, dayOf(e.start) + ', ' + e.time + (other ? ' · has a note' : ''), on);
824
+ }), evs.length ? null : el('p', { class: 'nt-hint' }, 'No calendar events on these days.'),
825
+ opt('adhoc', 'Not on the calendar', 'a hallway chat, a call', pick === 'adhoc'));
826
+ }
827
+ async function initiativeSheet(ctx, n) {
828
+ const ws = n.ws || wsOf(ctx);
829
+ const box = el('div', { class: 'nt-picks' }, el('p', { class: 'nt-hint' }, 'reading the initiatives …'));
830
+ let pick = n.initiative ? n.initiative.id : '';
831
+ let open = [];
832
+ const h = sheet({ title: 'Which initiative is this note about?', sub: 'The note shows on the initiative\'s page.', body: [box], attrs: { 'data-nt-ini-sheet': '' },
833
+ actions: [button({ label: 'Cancel', variant: 'ghost', onClick: () => h.close() }), button({ label: 'Save', variant: 'primary', attrs: { 'data-nt-ini-save': '' }, onClick: () => {
834
+ h.close();
835
+ if (pick === (n.initiative ? n.initiative.id : '') && (!n.initiative || n.initiative.by === 'pm')) return;
836
+ if (!pick) return link(ctx, n, { initiative: null }, () => 'Not linked to an initiative.');
837
+ const i = open.find((x) => x.id === pick) || { id: pick, title: pick };
838
+ return link(ctx, n, { initiative: { id: i.id, ws, repo: (ctx.state && ctx.state.product) || ctx.repo || null, title: i.title } }, () => 'Now on ' + i.title + '. The note shows on its page.');
839
+ } })] });
840
+ if (!ws) { box.replaceChildren(el('p', { class: 'nt-hint' }, 'Open a repository first: its initiatives are the ones a note can belong to.')); return; }
841
+ const r = await pmGet('/api/notes/initiatives?ws=' + encodeURIComponent(ws) + '&id=' + encodeURIComponent(n.id));
842
+ if (!h.open) return;
843
+ if (!r || !r.ok) { box.replaceChildren(el('p', { class: 'nt-hint', role: 'alert' }, (r && r.error) || 'The initiatives could not be read.')); return; }
844
+ open = r.open || [];
845
+ const radios = [];
846
+ const choose = (v) => { pick = v; for (const b of radios) b.setAttribute('aria-checked', b.dataset.ntIniPick === v ? 'true' : 'false'); if (sel.control.value !== v) sel.control.value = open.some((x) => x.id === v) ? v : ''; };
847
+ const opt = (v, title, sub) => { const b = button({ label: el('span', { class: 'nt-pick__txt' }, el('b', null, title), el('span', { class: 'nt-hint' }, sub)), variant: 'ghost', attrs: { class: 'nt-pick', role: 'radio', 'aria-checked': pick === v ? 'true' : 'false', 'data-nt-ini-pick': v }, onClick: () => choose(v) }); radios.push(b); return b; };
848
+ const sel = field({ label: 'Or choose any open initiative', kind: 'select', value: open.some((x) => x.id === pick) ? pick : '', options: [{ value: '', label: 'Choose …' }, ...open.map((x) => ({ value: x.id, label: x.title }))], attrs: { 'data-nt-ini-all': '' } });
849
+ sel.control.addEventListener('change', () => choose(sel.control.value));
850
+ const sugg = r.pick ? [el('h3', { class: 'nt-sec__h' }, 'Suggested from what was said'), opt(r.pick.id, r.pick.title, r.pick.shared + ' words in common')] : [];
851
+ const current = n.initiative && (!r.pick || r.pick.id !== n.initiative.id) ? [opt(n.initiative.id, n.initiative.title || n.initiative.id, BY_WORDS[n.initiative.by] || '')] : [];
852
+ box.replaceChildren(el('div', { role: 'radiogroup', 'aria-label': 'Initiative' }, ...sugg, ...current, opt('', 'None', 'not about an initiative')), open.length ? sel : el('p', { class: 'nt-hint' }, 'This repository has no open initiatives.'));
853
+ }
854
+ async function openInitiativeOf(ctx, ini) {
855
+ const reg = await import('./registry.js');
856
+ reg.openInitiative(ini.repo || (ctx.state && ctx.state.product) || ctx.repo || null, ini.id);
857
+ }
858
+ function linksLine(ctx, n) {
859
+ const m = n.meeting; const ini = n.initiative;
860
+ const meet = m && m.kind === 'event'
861
+ ? [el('b', null, m.title), el('span', { class: 'nt-hint' }, dayOf(m.start) + ', ' + nowClock(m.start) + (m.end ? '–' + nowClock(m.end) : ''))]
862
+ : m && m.kind === 'adhoc' ? [el('b', null, 'Not on the calendar'), el('span', { class: 'nt-hint' }, dayOf(n.created_at) + ', ' + nowClock(n.created_at))]
863
+ : [el('b', null, 'None')];
864
+ return el('div', { class: 'nt-links', 'data-nt-part': 'links' },
865
+ el('div', { class: 'nt-link', 'data-nt-link': 'meeting' }, el('span', { class: 'nt-link__k' }, 'Meeting'), ...meet,
866
+ button({ label: 'Change', variant: 'ghost', size: 'sm', onClick: () => meetingSheet(ctx, n), attrs: { 'data-nt-meeting': '' } })),
867
+ el('div', { class: 'nt-link', 'data-nt-link': 'initiative', 'data-nt-ini-by': ini ? ini.by : '' }, el('span', { class: 'nt-link__k' }, 'Initiative'),
868
+ ini ? [button({ label: ini.title || ini.id, variant: 'ghost', size: 'sm', onClick: () => openInitiativeOf(ctx, ini), attrs: { 'data-nt-ini-open': ini.id } }), el('span', { class: 'nt-hint' }, BY_WORDS[ini.by] || '')] : el('b', null, 'None'),
869
+ button({ label: 'Change', variant: 'ghost', size: 'sm', onClick: () => initiativeSheet(ctx, n), attrs: { 'data-nt-ini': '' } })));
870
+ }
765
871
  function listening(n) {
766
872
  if (audio && audio.id === n.id) return { kind: 'local', state: audio.state, sources: audio.sources, started: audio.started };
767
873
  if (speech && speech.id === n.id && speech.mode === 'record' && (speech.state === 'on' || speech.state === 'checking')) return { kind: 'browser', state: speech.state, sources: ['mic'], started: speech.started };
@@ -790,7 +896,7 @@ function header(ctx, n, ed) {
790
896
  }
791
897
  acts.push(button({ label: 'All notes', variant: 'ghost', onClick: () => { showList = !showList; ctx.setState({}); if (showList) loadList(ctx); }, attrs: { 'data-nt-all': '', 'aria-expanded': showList ? 'true' : 'false' } }));
792
898
  acts.push(iconButton({ icon: 'settings', ariaLabel: 'Settings', onClick: () => openSettings(ctx, n), attrs: { 'data-nt-settings': '' } }));
793
- return el('header', { class: 'nt-header', 'data-nt-part': 'header' }, left, el('div', { class: 'nt-acts' }, acts));
899
+ return el('header', { class: 'nt-header', 'data-nt-part': 'header' }, el('div', { class: 'nt-headwrap' }, left, linksLine(ctx, n)), el('div', { class: 'nt-acts' }, acts));
794
900
  }
795
901
  function listPane(ctx) {
796
902
  const rows = (list && list.notes) || [];
@@ -900,6 +1006,7 @@ export function renderBoard(inner, ctx) {
900
1006
  ensureComponentsCss();
901
1007
  if (FIXTURE) { inner.appendChild(el('p', { class: 'nt-hint' }, 'The fixture keeps no notes.')); return; }
902
1008
  if (!list) loadList(ctx);
1009
+ if (pendingOpen) { const id = pendingOpen; pendingOpen = null; if (id !== currentId) { selectNote(ctx, id); loadList(ctx); } }
903
1010
  if (!engine || engine.ws !== wsOf(ctx)) { if (!engine || !engine.loading || engine.ws !== wsOf(ctx)) loadEngine(ctx); }
904
1011
 
905
1012
  const focus = typeof document !== 'undefined' && editor && (document.activeElement === editor.body ? 'body' : document.activeElement === editor.title ? 'title' : null);
@@ -1,5 +1,5 @@
1
1
  /* lucide-sprite.js — GENERATED by workspace/third-party/build-lucide-sprite.js from lucide-static 1.52.0.
2
- * Do not edit by hand. The icons: grip-vertical, plus, check, square, square-check-big, x, undo-2, flag, bot, chevron-right, chevron-down, ellipsis, circle-alert, history, arrow-up, arrow-down, diamond, settings, search, refresh-cw, chevron-left, sun, moon, arrow-left, arrow-right, globe, columns-2, arrow-up-down, arrow-left-right, panel-top, panel-left, maximize-2, minimize-2, git-branch, clock.
2
+ * Do not edit by hand. The icons: grip-vertical, plus, check, square, square-check-big, x, undo-2, flag, bot, chevron-right, chevron-down, ellipsis, circle-alert, history, arrow-up, arrow-down, diamond, settings, search, refresh-cw, chevron-left, sun, moon, arrow-left, arrow-right, globe, columns-2, arrow-up-down, arrow-left-right, panel-top, panel-left, maximize-2, minimize-2, git-branch, clock, bold, italic, code, link, type, heading, list, list-checks, text-quote, file-code, git-compare, cloud, share-2.
3
3
  *
4
4
  * ISC License
5
5
  *
@@ -81,5 +81,18 @@ export const SPRITE = '<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true
81
81
  + '<symbol id="lucide-minimize-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m14 10 7-7" /><path d="M20 10h-6V4" /><path d="m3 21 7-7" /><path d="M4 14h6v6" /></symbol>'
82
82
  + '<symbol id="lucide-git-branch" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6a9 9 0 0 0-9 9V3" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /></symbol>'
83
83
  + '<symbol id="lucide-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></symbol>'
84
+ + '<symbol id="lucide-bold" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8" /></symbol>'
85
+ + '<symbol id="lucide-italic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" x2="10" y1="4" y2="4" /><line x1="14" x2="5" y1="20" y2="20" /><line x1="15" x2="9" y1="4" y2="20" /></symbol>'
86
+ + '<symbol id="lucide-code" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 18 6-6-6-6" /><path d="m8 6-6 6 6 6" /></symbol>'
87
+ + '<symbol id="lucide-link" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></symbol>'
88
+ + '<symbol id="lucide-type" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4v16" /><path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2" /><path d="M9 20h6" /></symbol>'
89
+ + '<symbol id="lucide-heading" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></symbol>'
90
+ + '<symbol id="lucide-list" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h.01" /><path d="M3 12h.01" /><path d="M3 19h.01" /><path d="M8 5h13" /><path d="M8 12h13" /><path d="M8 19h13" /></symbol>'
91
+ + '<symbol id="lucide-list-checks" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 5h8" /><path d="M13 12h8" /><path d="M13 19h8" /><path d="m3 17 2 2 4-4" /><path d="m3 7 2 2 4-4" /></symbol>'
92
+ + '<symbol id="lucide-text-quote" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 5H3" /><path d="M21 12H8" /><path d="M21 19H8" /><path d="M3 12v7" /></symbol>'
93
+ + '<symbol id="lucide-file-code" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" /><path d="M14 2v5a1 1 0 0 0 1 1h5" /><path d="M10 12.5 8 15l2 2.5" /><path d="m14 12.5 2 2.5-2 2.5" /></symbol>'
94
+ + '<symbol id="lucide-git-compare" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" /><path d="M13 6h3a2 2 0 0 1 2 2v7" /><path d="M11 18H8a2 2 0 0 1-2-2V9" /></symbol>'
95
+ + '<symbol id="lucide-cloud" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" /></symbol>'
96
+ + '<symbol id="lucide-share-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /><line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>'
84
97
  + '</svg>';
85
- export const ICONS = ["grip-vertical","plus","check","square","square-check-big","x","undo-2","flag","bot","chevron-right","chevron-down","ellipsis","circle-alert","history","arrow-up","arrow-down","diamond","settings","search","refresh-cw","chevron-left","sun","moon","arrow-left","arrow-right","globe","columns-2","arrow-up-down","arrow-left-right","panel-top","panel-left","maximize-2","minimize-2","git-branch","clock"];
98
+ export const ICONS = ["grip-vertical","plus","check","square","square-check-big","x","undo-2","flag","bot","chevron-right","chevron-down","ellipsis","circle-alert","history","arrow-up","arrow-down","diamond","settings","search","refresh-cw","chevron-left","sun","moon","arrow-left","arrow-right","globe","columns-2","arrow-up-down","arrow-left-right","panel-top","panel-left","maximize-2","minimize-2","git-branch","clock","bold","italic","code","link","type","heading","list","list-checks","text-quote","file-code","git-compare","cloud","share-2"];
@@ -80,6 +80,49 @@
80
80
  "no-network: the server process opens no HTTP client outside the one TypeSafe module (D79)",
81
81
  "no-network: lib/typesafe.js is the ONLY file that opens an HTTP client"
82
82
  ]
83
+ },
84
+ {
85
+ "id": "STD-R18",
86
+ "file": "workspace/pm-r18-test.js",
87
+ "run": "workspace-tabs-declutter",
88
+ "ref": "planning/initiatives/workspace-tabs-declutter.md (status, amended 2026-10-07 after the PM approved the clickable mockup)",
89
+ "reason": "A file that cannot be edited now has one Source toggle instead of the view and raw modes. The assertion keeps its name and still checks that a git file offers no edit, diff or save; it reads the new control names (source, markdown, changes).",
90
+ "names": [
91
+ "R5: a git file offers no edit, diff or save"
92
+ ]
93
+ },
94
+ {
95
+ "id": "STD-R21",
96
+ "file": "workspace/pm-r21-test.js",
97
+ "run": "workspace-tabs-declutter",
98
+ "ref": "planning/initiatives/workspace-tabs-declutter.md (status, amended 2026-10-07 after the PM approved the clickable mockup)",
99
+ "reason": "The four document modes became toggles: a markdown file is edited in place with a Markdown toggle, and a read-only file has a Source toggle. F7 now checks that the source is one toggle away; F10 checks that a markdown tab is edited in place and offers its markdown.",
100
+ "names": [
101
+ "F7: view and raw are still offered",
102
+ "F10: a markdown tab still offers edit"
103
+ ]
104
+ },
105
+ {
106
+ "id": "STD-TABS",
107
+ "file": "workspace/pm-tabs-test.js",
108
+ "run": "workspace-tabs-declutter",
109
+ "ref": "planning/initiatives/workspace-tabs-declutter.md (status, amended 2026-10-07 after the PM approved the clickable mockup)",
110
+ "reason": "A markdown file has no raw mode any more: its source is the Markdown toggle, a textarea that scrolls itself. The scroll-position assertion now holds the Markdown source's position. pm-doc-inplace-test.js asserts the in-place editor.",
111
+ "names": [
112
+ "raw mode holds its position too — the fix is the pane's, not one mode's"
113
+ ]
114
+ },
115
+ {
116
+ "id": "STD-UI",
117
+ "file": "workspace/ui-test.js",
118
+ "run": "workspace-tabs-declutter",
119
+ "ref": "planning/initiatives/workspace-tabs-declutter.md (status, amended 2026-10-07 after the PM approved the clickable mockup)",
120
+ "reason": "The document round trip now goes through the in-place editor: DOC1 checks the tab is edited in place with a Markdown toggle (not four modes), DOC2 counts the Properties inputs (one per frontmatter key) instead of the old card's spans, and DOC4 reads the whole file from the Markdown toggle's textarea instead of raw. DOC5 and DOC6 keep their names and still type, diff and save the real file.",
121
+ "names": [
122
+ "DOC1: a document opens as a doc tab offering view · raw · edit · diff",
123
+ "DOC4: raw shows the file as written, frontmatter fence and all",
124
+ "DOC2: view renders the FILE ON DISK as markdown — a heading, its frontmatter card, its list"
125
+ ]
83
126
  }
84
127
  ],
85
128
  "entries": [
@@ -119,6 +119,8 @@ const startPolicy = require('./lib/start-policy'); // ONE stated rule for wh
119
119
  const treeShare = require('./lib/tree-share'); // who shares a working tree, and who is DECLARED to be driving it
120
120
  const tmuxControl = require('./lib/tmux-control'); // tmux -CC protocol: the pane's raw stream, so xterm.js keeps its scrollback
121
121
  const relatedDocs = require('./lib/related-docs'); // which documents a change puts out of step (R5/R6)
122
+ const Kg = require('./lib/kg'); // the stated ties between files (pmos kg)
123
+ const kgCli = require('./lib/kg-cli'); // …and its cached build for one repository
122
124
  const applyChange = require('./lib/apply-change'); // anchored + whole-document saves, refuse-on-stale
123
125
  const sessionState = require('./lib/session-state'); // the cadence×turn join — validated on BOTH sides
124
126
  // Bundle 2 (planning/design/bundle-2-wiring-contract.md). Four already-built pure modules. The
@@ -138,6 +140,7 @@ const docCheckSessions = new Map();
138
140
  // The session each Use … version press started (workspace id|path|kind → { session, use }), so the line says "Updating…"
139
141
  // before the session has opened its sync, and a second press on the same copy refuses.
140
142
  const docSyncSessions = new Map();
143
+ const docGraphs = new Map(); // root -> { at, graph }: the document tab's Connections, held 15 s
141
144
  const jiraTickets = require('./lib/jira-tickets.js'); // jira-wbs — the ticket draft, the PM's selection, the push plan
142
145
  const jiraStatus = require('./lib/jira-status.js'); // …and the status snapshot an agent's read writes
143
146
  // The session each Push or Check now press started (workspace id|initiative → { session, at }), so a second press
@@ -8248,6 +8251,33 @@ async function handleRequest(req, res) {
8248
8251
  }
8249
8252
  return json(res, { ok: true, wrote: rel });
8250
8253
  }
8254
+ if (p === '/api/doc/graph') {
8255
+ // The document tab's Connections (workspace-tabs-declutter): the files this file is tied to, read from the same
8256
+ // derived graph `pmos kg neighbors` prints. Only stated ties: a link, a frontmatter path, an initiative, an anchor
8257
+ // or a decision id. Nothing is guessed. The graph is built from git's tracked Markdown and cached by HEAD and the
8258
+ // dirty .md files, so a second read is a file read; a short in-memory hold keeps a repaint from asking git again.
8259
+ const w = getWs(url.searchParams.get('ws'));
8260
+ if (!w) return json(res, { ok: false, error: 'no workspace' }, 404);
8261
+ const rel = String(url.searchParams.get('path') || '');
8262
+ if (!rel || !insideRoot(w.root, path.resolve(w.root, rel))) return json(res, { ok: false, error: 'outside workspace' }, 404);
8263
+ let held = docGraphs.get(w.root);
8264
+ // A folder that is not a git repository, or one with no commit yet, tracks no file, so no tie is known.
8265
+ if (!held && spawnSync('git', ['rev-parse', '--verify', '-q', 'HEAD'], { cwd: w.root, stdio: 'ignore' }).status !== 0) {
8266
+ return json(res, { ok: true, tracked: false, ties: [] });
8267
+ }
8268
+ if (!held || Date.now() - held.at > 15000) {
8269
+ try { held = { at: Date.now(), graph: kgCli.graphFor(w.root).graph }; docGraphs.set(w.root, held); }
8270
+ catch (e) { return json(res, { ok: false, error: 'the graph could not be built: ' + ((e && e.message) || e).toString().split('\n')[0] }, 500); }
8271
+ }
8272
+ const g = held.graph;
8273
+ const me = Kg.nodeById(g, rel);
8274
+ if (!me) return json(res, { ok: true, tracked: false, ties: [] });
8275
+ const ties = (Kg.neighbors(g, rel, { depth: 1 }) || []).slice(0, 40).map((r) => {
8276
+ const n = Kg.nodeById(g, r.id) || {};
8277
+ return { path: r.id, title: n.title || r.id, type: n.type || null, via: r.via, basis: r.basis, direction: r.direction };
8278
+ });
8279
+ return json(res, { ok: true, tracked: true, title: me.title, ties });
8280
+ }
8251
8281
  if (p === '/api/doc/changes') {
8252
8282
  // The change log, read back (R8 — this is what an agent reads before working here).
8253
8283
  const w = getWs(url.searchParams.get('ws'));
@@ -0,0 +1,13 @@
1
+ {
2
+ "_comment": "workspace-tabs-declutter: the document tab edited in place (block model, browser).",
3
+ "modules": [
4
+ "doc-editor.test.mjs"
5
+ ],
6
+ "seconds": {
7
+ "doc-editor.test.mjs": 1,
8
+ "pm-doc-inplace-test.js": 25
9
+ },
10
+ "panels": [
11
+ "pm-doc-inplace-test.js"
12
+ ]
13
+ }
@@ -12,7 +12,7 @@ The workspace runs with no network (D62). Two third-party browser files are comm
12
12
  | Package | Version | Licence | Tarball | Tarball sha256 | Shipped file |
13
13
  |---|---|---|---|---|---|
14
14
  | sortablejs | 1.15.7 | MIT | https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz | `66cbfc8fa18860783584fe0c346baf6cbf3399da72d23d21053425ba232abed4` | `public/vendor/Sortable.min.js`: an unmodified copy of `package/Sortable.min.js` (45 478 bytes, sha256 `bf4241bc73fef7f11c59a283a69fe8051cdd31c6d8ff5a2b9ba219e7831fcf76`; npm integrity `sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==`) |
15
- | lucide-static | 1.52.0 | ISC, plus the Feather MIT notice | https://registry.npmjs.org/lucide-static/-/lucide-static-1.52.0.tgz | `4d2aa079171e4eba4c704844b8b0c9edb6e4cfb2fb8bd86f7b14353994f04a87` | `public/vendor/lucide-sprite.js`: generated by `build-lucide-sprite.js` (13 525 bytes, sha256 `3bb7ec3369206ff1bc8c6ae5dfd91c5d99e5ec89c4357d18b744b594ea366447`). It holds 35 icons: grip-vertical, plus, check, square, square-check-big, x, undo-2, flag, bot, chevron-right, chevron-down, ellipsis, circle-alert, history, arrow-up, arrow-down, diamond, then the eight the frame's chrome draws (Run 3): settings, search, refresh-cw, chevron-left, sun, moon, arrow-left, arrow-right, then globe, columns-2, arrow-up-down and arrow-left-right for the dock strip's web, split and orientation chips (Run 3, C6), then panel-top, panel-left, maximize-2, minimize-2, git-branch and clock for the dock's icon controls and session rows (0.54.2) |
15
+ | lucide-static | 1.52.0 | ISC, plus the Feather MIT notice | https://registry.npmjs.org/lucide-static/-/lucide-static-1.52.0.tgz | `4d2aa079171e4eba4c704844b8b0c9edb6e4cfb2fb8bd86f7b14353994f04a87` | `public/vendor/lucide-sprite.js`: generated by `build-lucide-sprite.js` (17 352 bytes, sha256 `7f9e952399a3c1c91659a527bc1363a008b531ed6e296c2b3c819554ffb0a56f`). It holds 48 icons: grip-vertical, plus, check, square, square-check-big, x, undo-2, flag, bot, chevron-right, chevron-down, ellipsis, circle-alert, history, arrow-up, arrow-down, diamond, then the eight the frame's chrome draws (Run 3): settings, search, refresh-cw, chevron-left, sun, moon, arrow-left, arrow-right, then globe, columns-2, arrow-up-down and arrow-left-right for the dock strip's web, split and orientation chips (Run 3, C6), then panel-top, panel-left, maximize-2, minimize-2, git-branch and clock for the dock's icon controls and session rows (0.54.2), then bold, italic, code, link, type, heading, list, list-checks, text-quote, file-code, git-compare, cloud and share-2 for the document tab's in-place editor, its Confluence copy and its connections |
16
16
 
17
17
  The licences are in this folder: [sortablejs-1.15.7.LICENSE](sortablejs-1.15.7.LICENSE) and [lucide-static-1.52.0.LICENSE](lucide-static-1.52.0.LICENSE). The sprite also carries the lucide licence text as its first comment.
18
18