create-pmos 0.55.5 → 0.56.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.
@@ -0,0 +1,405 @@
1
+ /* pm/dest-docs.js — the Docs destination: is each document the same as its Google Docs and Confluence copy, and use
2
+ * one version over the other on the PM's press (initiative docs-three-way-sync, PRD R-D1 to R-D6 and R-F1 to R-F4).
3
+ *
4
+ * A DESTINATION MODULE (registry.js): mountDestination(host, ctx) on every render with the same host, disposeDestination(host).
5
+ * It keeps ONE persistent root per host; the header (title and search) is built once, so typing never loses focus, and
6
+ * only the body is rebuilt when its own data changed.
7
+ *
8
+ * What it shows, for the repository the tab names (or the open one):
9
+ * - Docs, a search box, Needs you · All, and when the copies were last compared, with a control that compares again;
10
+ * - the copies grouped by initiative, ONE line per copy: a status dot, the title, a link that opens the copy, one
11
+ * sentence (which side is newer, and who edited it last) and at most one button (Use PMOS version, Use Confluence
12
+ * version, or Choose… when both changed);
13
+ * - a click on a line: both sides with where they live and their last edit, a Use … version button for each, the first
14
+ * difference the check saw, Compare again and Stop tracking this copy;
15
+ * - search: every markdown file that matches; a file without a copy offers Add copy, in place.
16
+ * The signal is decided on the server (lib/doc-sync.js) from what a check session read. Nothing here reads or writes
17
+ * Google Docs or Confluence. Use … version waits five seconds behind Undo, then asks the server to start ONE doc-sync
18
+ * session for that copy, which writes one side with the PM's own connectors (the PM's ruling, amended 2026-10-06).
19
+ * In fixture mode it reads nothing.
20
+ */
21
+
22
+ import { el } from './dom.js';
23
+ import { button, row, field, toast, empty, tabs, ensureComponentsCss } from './primitives.js';
24
+ import { ensureSprite } from './icons.js';
25
+ import { pmGet, pmPost } from './http.js';
26
+ import { wsOf } from './data.js';
27
+ import { registerCommand, openDestination } from './registry.js';
28
+
29
+ const LABEL = { google: 'Google Docs', confluence: 'Confluence' };
30
+ const UNDO_MS = 5000;
31
+
32
+ export const COPY = Object.freeze({
33
+ title: 'Docs',
34
+ fixture: 'Docs shows linked documents in a real session.',
35
+ noRepo: 'Open a repository to see its documents.',
36
+ loading: 'Reading…',
37
+ readError: 'The documents could not be read.',
38
+ find: 'Find a document',
39
+ needs: 'Needs you',
40
+ all: 'All',
41
+ compared: (iso) => 'Compared ' + ago(iso),
42
+ never: 'Not compared yet',
43
+ comparing: 'Comparing…',
44
+ compareAll: 'Compare every copy again',
45
+ none: 'No document has a copy yet. Find a document above and press Add copy.',
46
+ nothingNeeds: 'Nothing needs you. Every copy matches PMOS.',
47
+ noMatch: (q) => `No document matches “${q}”.`,
48
+ other: 'Other',
49
+ usePmos: 'Use PMOS version',
50
+ useCopy: (label) => `Use ${label} version`,
51
+ choose: 'Choose…',
52
+ noCopy: 'No copy',
53
+ addCopy: 'Add copy',
54
+ add: 'Add',
55
+ cancel: 'Cancel',
56
+ paste: 'Paste a Google Docs or Confluence link',
57
+ badPaste: 'Paste the address of a Google Doc or a Confluence page.',
58
+ added: 'Copy added. Compare to see which side is newer.',
59
+ compareAgain: 'Compare again',
60
+ stop: 'Stop tracking this copy',
61
+ stopped: (label) => `Stopped tracking the ${label} copy. Nothing was deleted.`,
62
+ undo: 'Undo',
63
+ pushing: (label) => `Updating ${label} with the PMOS version.`,
64
+ pulling: (label) => `Updating PMOS with the ${label} version.`,
65
+ cancelled: 'Nothing was changed.',
66
+ started: 'Compare started. It reads every copy and changes nothing.',
67
+ });
68
+
69
+ // Docs is not in the top bar (the bar holds at most 7 controls); ⌘K and `g d` open it.
70
+ registerCommand({ id: 'docs-open', label: 'Docs', hint: 'Is each document the same as its Google Docs and Confluence copy', cli: 'pmos docs list', run: () => openDestination('docs') });
71
+
72
+ /* ── words ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
73
+
74
+ function ago(iso) {
75
+ if (!iso) return 'never';
76
+ const m = Math.round((Date.now() - Date.parse(iso)) / 60000);
77
+ if (m < 1) return 'just now';
78
+ if (m < 60) return `${m} min ago`;
79
+ if (m < 48 * 60) return `${Math.round(m / 60)} h ago`;
80
+ return `${Math.round(m / 1440)} days ago`;
81
+ }
82
+ /** "today 09:12", "yesterday 17:55", "Mon 14:05", "Sep 30". */
83
+ function when(iso) {
84
+ const t = new Date(iso);
85
+ if (Number.isNaN(t.getTime())) return '';
86
+ const hm = t.toTimeString().slice(0, 5);
87
+ const day = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
88
+ const days = Math.round((day(new Date()) - day(t)) / 86400000);
89
+ if (days === 0) return 'today ' + hm;
90
+ if (days === 1) return 'yesterday ' + hm;
91
+ if (days > 1 && days < 7) return t.toLocaleDateString('en-US', { weekday: 'short' }) + ' ' + hm;
92
+ return t.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
93
+ }
94
+ const edit = (by, at) => [by, at ? when(at) : null].filter(Boolean).join(', ');
95
+ const keyOf = (doc, link) => `${doc.path}|${link.kind}`;
96
+ /** Which kind a pasted address names. The server checks it again; this only picks the kind. */
97
+ function kindOf(target) {
98
+ const t = String(target || '');
99
+ if (/atlassian|confluence|\/wiki\/|pageId=/i.test(t)) return 'confluence';
100
+ if (/google|docs\.|drive\./i.test(t)) return 'google';
101
+ return null;
102
+ }
103
+
104
+ /** One dot, one sentence and at most one button per copy. The newer side's last edit is the only detail on the line. */
105
+ function stateOf(inst, doc, link) {
106
+ const L = link.label || LABEL[link.kind];
107
+ const pend = inst.pending.get(keyOf(doc, link));
108
+ const syncing = link.syncing || (pend ? { use: pend.use } : null);
109
+ if (syncing) return { dot: 'none', strong: syncing.use === 'pmos' ? `Updating ${L}…` : `Updating PMOS from ${L}…` };
110
+ const by = (s) => (link.basis === 'date' ? s + ' · by date' : s);
111
+ let st;
112
+ switch (link.signal) {
113
+ case 'same': st = { dot: 'ok', rest: 'Same' }; break;
114
+ case 'ahead': st = { dot: 'warn', strong: 'PMOS is newer', rest: by(edit(doc.repo && doc.repo.modified_by, doc.repo && doc.repo.modified_at)), btn: 'pmos' }; break;
115
+ case 'behind': st = { dot: 'warn', strong: `${L} is newer`, rest: by(edit(link.remote_modified_by, link.remote_modified_at)), btn: 'copy' }; break;
116
+ case 'diverged': st = { dot: 'bad', strong: 'Edited on both sides', btn: 'choose' }; break;
117
+ case 'unchecked': st = { dot: 'none', rest: COPY.never }; break;
118
+ case 'unreadable': st = { dot: 'bad', strong: `Can't open in ${L}`, rest: String(link.words || '').replace(/^Could not check — /, '') }; break;
119
+ case 'missing-remote': st = { dot: 'bad', strong: `Not found in ${L}` }; break;
120
+ case 'missing-repo': st = { dot: 'bad', strong: 'Not in PMOS any more' }; break;
121
+ default: st = { dot: 'none', rest: link.words || '' };
122
+ }
123
+ if (link.sync_failed) return { ...st, dot: 'bad', strong: 'Sync failed', rest: link.sync_failed.reason };
124
+ return st;
125
+ }
126
+
127
+ /* ── mount ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
128
+
129
+ const insts = new WeakMap();
130
+ const REREAD_MS = 5000;
131
+
132
+ export function mountDestination(host, ctx) {
133
+ let inst = insts.get(host);
134
+ if (!inst) {
135
+ inst = { host, root: el('div', { class: 'rp-page rp-docs', 'data-docs': '' }), head: null, body: el('div', { class: 'rp-docs-body' }),
136
+ ws: null, loaded: false, error: null, view: null, filter: 'needs', q: '', files: [], open: null, adding: null,
137
+ pending: new Map(), busy: false, seq: 0, fseq: 0, last: '', timer: null, ftimer: null };
138
+ insts.set(host, inst);
139
+ }
140
+ ensureComponentsCss();
141
+ if (!inst.root.isConnected || inst.root.parentNode !== host) host.replaceChildren(inst.root);
142
+ if (ctx && ctx.FIXTURE) { inst.root.replaceChildren(empty({ text: COPY.fixture })); return; }
143
+ const repo = (ctx && ctx.tab && ctx.tab.repo) || (ctx && ctx.state && ctx.state.product) || null;
144
+ const ws = repo ? wsOf(repo) : null;
145
+ if (ws !== inst.ws) {
146
+ stopPoll(inst);
147
+ Object.assign(inst, { ws, loaded: false, error: null, view: null, last: '', open: null, adding: null, files: [] });
148
+ if (ws) { ensureSprite(); refresh(inst); }
149
+ draw(inst);
150
+ return;
151
+ }
152
+ // Coming back to Docs reads again (at most every few seconds): a file edited since shows its new signal.
153
+ if (Date.now() - (inst.readAt || 0) > REREAD_MS) refresh(inst);
154
+ if (!inst.last) draw(inst);
155
+ }
156
+
157
+ export function disposeDestination(host) {
158
+ const inst = insts.get(host);
159
+ if (!inst) return;
160
+ stopPoll(inst);
161
+ inst.seq++;
162
+ inst.root.remove();
163
+ insts.delete(host);
164
+ }
165
+
166
+ function stopPoll(inst) { if (inst.timer) { clearInterval(inst.timer); inst.timer = null; } }
167
+ /** While a compare or a sync runs, read again every few seconds so its result lands without a reload; stop when it ends. */
168
+ function pollWhileRunning(inst) {
169
+ const v = inst.view;
170
+ const running = v && (v.open || v.session || v.docs.some((d) => d.links.some((l) => l.syncing)));
171
+ if (running && !inst.timer) inst.timer = setInterval(() => refresh(inst), 4000);
172
+ if (!running) stopPoll(inst);
173
+ }
174
+
175
+ async function refresh(inst) {
176
+ const ws = inst.ws;
177
+ if (!ws) return;
178
+ const seq = ++inst.seq;
179
+ inst.readAt = Date.now();
180
+ const v = await pmGet('/api/docs?ws=' + encodeURIComponent(ws));
181
+ if (seq !== inst.seq || ws !== inst.ws) return;
182
+ inst.loaded = true;
183
+ if (!v || v.ok === false) { inst.error = (v && v.error) || COPY.readError; inst.view = null; }
184
+ else { inst.error = null; inst.view = v; }
185
+ pollWhileRunning(inst);
186
+ draw(inst);
187
+ }
188
+
189
+ /** The repository's files that match the search and have no copy yet: what Add copy is offered on. */
190
+ function searchFiles(inst) {
191
+ if (inst.ftimer) clearTimeout(inst.ftimer);
192
+ const q = inst.q.trim();
193
+ if (!q) { inst.files = []; return; }
194
+ inst.ftimer = setTimeout(async () => {
195
+ const seq = ++inst.fseq;
196
+ const r = await pmGet('/api/docs/files?ws=' + encodeURIComponent(inst.ws) + '&q=' + encodeURIComponent(q));
197
+ if (seq !== inst.fseq || q !== inst.q.trim()) return;
198
+ inst.files = (r && r.ok && r.files) || [];
199
+ draw(inst);
200
+ }, 200);
201
+ }
202
+
203
+ /* ── actions ─────────────────────────────────────────────────────────────────────────────────────────────────────── */
204
+
205
+ async function compareAll(inst) {
206
+ inst.busy = true; draw(inst);
207
+ const r = await pmPost('/api/docs/check', { ws: inst.ws });
208
+ inst.busy = false;
209
+ if (r && r.ok) toast({ text: COPY.started, timeout: 4000 });
210
+ else toast({ text: (r && r.error) || 'The compare could not start.', tone: 'error' });
211
+ await refresh(inst);
212
+ }
213
+
214
+ /** Use one version over the other. Held for five seconds behind Undo; only then is the session asked for. */
215
+ function useVersion(inst, doc, link, use) {
216
+ const key = keyOf(doc, link);
217
+ if (inst.pending.has(key)) return;
218
+ const L = link.label || LABEL[link.kind];
219
+ const p = { use, timer: null, toast: null };
220
+ p.timer = setTimeout(async () => {
221
+ inst.pending.delete(key);
222
+ const r = await pmPost('/api/docs/sync', { ws: inst.ws, path: doc.path, kind: link.kind, use });
223
+ if (!r || !r.ok) toast({ text: (r && r.error) || 'The sync could not start.', tone: 'error' });
224
+ await refresh(inst);
225
+ }, UNDO_MS);
226
+ p.toast = toast({ text: use === 'pmos' ? COPY.pushing(L) : COPY.pulling(L), actionLabel: COPY.undo, timeout: UNDO_MS,
227
+ onAction: () => { clearTimeout(p.timer); inst.pending.delete(key); draw(inst); toast({ text: COPY.cancelled, timeout: 3000 }); } });
228
+ inst.pending.set(key, p);
229
+ inst.open = null;
230
+ draw(inst);
231
+ }
232
+
233
+ async function stopTracking(inst, doc, link) {
234
+ const r = await pmPost('/api/docs/unlink', { ws: inst.ws, path: doc.path, kind: link.kind });
235
+ if (!r || !r.ok) { toast({ text: (r && r.error) || 'The copy is still tracked.', tone: 'error' }); return; }
236
+ inst.open = null;
237
+ toast({ text: COPY.stopped(link.label || LABEL[link.kind]), actionLabel: COPY.undo, timeout: 6000,
238
+ onAction: async () => { await pmPost('/api/docs/link', { ws: inst.ws, path: doc.path, kind: link.kind, target: link.target }); refresh(inst); } });
239
+ refresh(inst);
240
+ }
241
+
242
+ async function addCopy(inst, path, target, err) {
243
+ const kind = kindOf(target);
244
+ if (!kind) { err.textContent = COPY.badPaste; return; }
245
+ const r = await pmPost('/api/docs/link', { ws: inst.ws, path, kind, target });
246
+ if (!r || !r.ok) { err.textContent = (r && r.error) || 'The copy was not added.'; return; }
247
+ inst.adding = null;
248
+ inst.files = inst.files.filter((f) => f.path !== path);
249
+ toast({ text: COPY.added, timeout: 4000 });
250
+ refresh(inst);
251
+ }
252
+
253
+ /* ── lines ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
254
+
255
+ function copyLine(inst, doc, link) {
256
+ const key = keyOf(doc, link);
257
+ const L = link.label || LABEL[link.kind];
258
+ const st = stateOf(inst, doc, link);
259
+ const open = inst.open === key;
260
+ const toggle = () => { inst.open = open ? null : key; draw(inst); };
261
+ let act = null;
262
+ if (st.btn === 'pmos' || st.btn === 'copy') {
263
+ act = button({ label: st.btn === 'pmos' ? COPY.usePmos : COPY.useCopy(L), variant: 'primary', size: 'sm',
264
+ attrs: { 'data-docs-act': 'use-' + st.btn, 'data-docs-key': key }, onClick: () => useVersion(inst, doc, link, st.btn) });
265
+ } else if (st.btn === 'choose') {
266
+ act = button({ label: COPY.choose, variant: 'outline', size: 'sm', attrs: { 'data-docs-act': 'choose', 'data-docs-key': key, 'aria-expanded': open ? 'true' : 'false' },
267
+ onClick: toggle });
268
+ }
269
+ const opener = link.url
270
+ ? el('a', { class: 'rp-docs-open', href: link.url, target: '_blank', rel: 'noopener noreferrer', 'data-docs-open': link.kind }, L + ' ↗')
271
+ : el('span', { class: 'rp-docs-open' }, L);
272
+ const line = row({
273
+ title: doc.title,
274
+ meta: [st.strong, st.rest].filter(Boolean),
275
+ lead: [el('span', { class: 'rp-docs-dot rp-docs-dot--' + st.dot, 'aria-hidden': 'true' })],
276
+ trail: [opener, act].filter(Boolean),
277
+ onOpen: toggle,
278
+ openLabel: `${doc.title}, ${L}: ${[st.strong, st.rest].filter(Boolean).join(', ')}`,
279
+ openAttrs: { 'aria-expanded': open ? 'true' : 'false', title: doc.path, 'data-docs-toggle': key },
280
+ attrs: { 'data-docs-line': key, 'data-docs-signal': link.signal, 'data-docs-kind': link.kind, 'data-docs-title': doc.title,
281
+ 'data-docs-state': [st.strong, st.rest].filter(Boolean).join(' · ') },
282
+ });
283
+ const wrap = el('div', { class: 'rp-docs-row', role: 'listitem', 'data-docs-doc': doc.path }, line);
284
+ if (open) wrap.appendChild(details(inst, doc, link, st));
285
+ return wrap;
286
+ }
287
+
288
+ /** The details, on a click: where each side lives, its last edit, both choices, and the first difference the check saw. */
289
+ function details(inst, doc, link, st) {
290
+ const L = link.label || LABEL[link.kind];
291
+ const can = !st.strong || !/^Updating/.test(st.strong);
292
+ const offer = can && ['ahead', 'behind', 'diverged'].includes(link.signal);
293
+ const pNew = link.signal === 'ahead';
294
+ const cNew = link.signal === 'behind';
295
+ const side = (name, where, last, isNew, use) => el('div', { class: 'rp-docs-side' + (isNew ? ' rp-docs-side--new' : ''), 'data-docs-side': use },
296
+ el('span', { class: 'rp-docs-side__label' }, name + (isNew ? ' · newer' : '')),
297
+ el('span', { class: 'rp-docs-side__where' }, where),
298
+ el('span', { class: 'rp-docs-side__edit' }, last ? 'Last edit: ' + last : 'Last edit: read at the next compare'),
299
+ offer ? button({ label: use === 'pmos' ? COPY.usePmos : COPY.useCopy(L), variant: isNew ? 'primary' : 'outline', size: 'sm',
300
+ attrs: { 'data-docs-act': 'use-' + use, 'data-docs-key': keyOf(doc, link) }, onClick: () => useVersion(inst, doc, link, use) }) : null);
301
+ const diff = link.note && link.signal !== 'same' && link.signal !== 'unchecked'
302
+ ? el('p', { class: 'rp-docs-diff', 'data-docs-diff': '' }, 'First difference: ' + link.note) : null;
303
+ return el('div', { class: 'rp-docs-detail', 'data-docs-detail': keyOf(doc, link) },
304
+ side('PMOS', doc.path, edit(doc.repo && doc.repo.modified_by, doc.repo && doc.repo.modified_at), pNew, 'pmos'),
305
+ side(L, link.target, edit(link.remote_modified_by, link.remote_modified_at), cNew, 'copy'),
306
+ diff,
307
+ el('div', { class: 'rp-docs-more' },
308
+ button({ label: COPY.compareAgain, variant: 'ghost', size: 'sm', disabled: !!(inst.view && (inst.view.open || inst.view.session)) || inst.busy,
309
+ attrs: { 'data-docs-act': 'compare-one' }, onClick: () => compareAll(inst) }),
310
+ button({ label: COPY.stop, variant: 'ghost', size: 'sm', attrs: { 'data-docs-act': 'stop', 'data-docs-key': keyOf(doc, link) },
311
+ onClick: () => stopTracking(inst, doc, link) })));
312
+ }
313
+
314
+ /** A file with no copy: "No copy" and Add copy, which opens a paste field in place. */
315
+ function fileLine(inst, f) {
316
+ const start = () => { inst.adding = f.path; draw(inst); };
317
+ const adding = inst.adding === f.path;
318
+ const line = row({
319
+ title: f.title,
320
+ meta: [COPY.noCopy],
321
+ lead: [el('span', { class: 'rp-docs-dot rp-docs-dot--none', 'aria-hidden': 'true' })],
322
+ trail: adding ? [] : [button({ label: COPY.addCopy, variant: 'outline', size: 'sm', attrs: { 'data-docs-act': 'add-copy', 'data-docs-key': f.path }, onClick: start })],
323
+ attrs: { 'data-docs-file-line': f.path, title: f.path },
324
+ });
325
+ const wrap = el('div', { class: 'rp-docs-row', role: 'listitem', 'data-docs-file': f.path }, line);
326
+ if (!adding) return wrap;
327
+ const err = el('span', { class: 'rp-docs-add__err', role: 'alert' });
328
+ const inp = field({ label: `Copy of ${f.title}`, hideLabel: true, placeholder: COPY.paste, attrs: { 'data-docs-field': 'target' } });
329
+ inp.control.addEventListener('keydown', (e) => {
330
+ if (e.key === 'Enter') addCopy(inst, f.path, inp.control.value.trim(), err);
331
+ if (e.key === 'Escape') { inst.adding = null; draw(inst); }
332
+ });
333
+ setTimeout(() => inp.control.focus(), 0);
334
+ wrap.appendChild(el('div', { class: 'rp-docs-add' }, el('div', { class: 'rp-docs-add__input' }, inp),
335
+ button({ label: COPY.add, variant: 'primary', size: 'sm', attrs: { 'data-docs-act': 'add-save' }, onClick: () => addCopy(inst, f.path, inp.control.value.trim(), err) }),
336
+ button({ label: COPY.cancel, variant: 'ghost', size: 'sm', onClick: () => { inst.adding = null; draw(inst); } }),
337
+ err));
338
+ return wrap;
339
+ }
340
+
341
+ /* ── the page ────────────────────────────────────────────────────────────────────────────────────────────────────── */
342
+
343
+ function header(inst) {
344
+ if (inst.head) return inst.head;
345
+ const find = field({ label: COPY.find, hideLabel: true, placeholder: COPY.find, attrs: { 'data-docs-find': '' } });
346
+ const q = find.control;
347
+ q.addEventListener('input', () => { inst.q = q.value; inst.open = null; inst.adding = null; searchFiles(inst); draw(inst); });
348
+ // The field restores its last committed text on Escape; here Escape clears the search instead.
349
+ q.addEventListener('keydown', (e) => { if (e.key === 'Escape') { q.resetCommitted(''); inst.q = ''; searchFiles(inst); draw(inst); } });
350
+ inst.head = el('div', { class: 'rp-docs-top' }, el('h2', { class: 'rp-head__title' }, COPY.title), el('div', { class: 'rp-docs-find' }, find));
351
+ return inst.head;
352
+ }
353
+
354
+ function grouped(inst, docs) {
355
+ const by = new Map();
356
+ for (const d of docs) {
357
+ const k = d.initiative ? d.initiative.id : '';
358
+ if (!by.has(k)) by.set(k, { title: d.initiative ? d.initiative.title : COPY.other, docs: [] });
359
+ by.get(k).docs.push(d);
360
+ }
361
+ return [...by.entries()].sort((a, b) => (a[0] === '') - (b[0] === '')).map(([k, g]) =>
362
+ el('section', { class: 'rp-docs-group', 'data-docs-group': k || 'other' },
363
+ el('h3', { class: 'rp-docs-group__title' }, g.title),
364
+ el('div', { role: 'list' }, g.docs.flatMap((d) => d.links.map((l) => copyLine(inst, d, l))))));
365
+ }
366
+
367
+ function body(inst) {
368
+ const v = inst.view;
369
+ const q = inst.q.trim().toLowerCase();
370
+ if (q) {
371
+ const hits = v.docs.filter((d) => [d.title, d.path, d.initiative ? d.initiative.title : ''].join(' ').toLowerCase().includes(q));
372
+ const rows = [...hits.flatMap((d) => d.links.map((l) => copyLine(inst, d, l))), ...inst.files.map((f) => fileLine(inst, f))];
373
+ return rows.length ? [el('div', { role: 'list', class: 'rp-docs-group', 'data-docs-results': '' }, rows)] : [el('p', { class: 'rp-section__empty' }, COPY.noMatch(inst.q.trim()))];
374
+ }
375
+ const all = v.docs.reduce((n, d) => n + d.links.length, 0);
376
+ const needs = v.docs.reduce((n, d) => n + d.links.filter((l) => l.signal !== 'same' || l.syncing).length, 0);
377
+ const running = !!(v.open || v.session) || inst.busy;
378
+ const out = [el('div', { class: 'rp-docs-views' },
379
+ tabs({ variant: 'line', items: [{ id: 'needs', label: COPY.needs, count: needs }, { id: 'all', label: COPY.all, count: all }],
380
+ value: inst.filter, ariaLabel: 'Filter', attrs: { 'data-docs-filter': '' }, onChange: (val) => { inst.filter = val; draw(inst); } }),
381
+ el('span', { class: 'rp-docs-when', 'data-docs-summary': '' },
382
+ running ? COPY.comparing : (v.last ? COPY.compared(v.last.finished_at) : COPY.never),
383
+ button({ label: '↻', variant: 'ghost', size: 'sm', disabled: running || !all,
384
+ attrs: { 'data-docs-act': 'check', 'aria-label': COPY.compareAll, title: COPY.compareAll }, onClick: () => compareAll(inst) })))];
385
+ if (!v.docs.length) { out.push(el('p', { class: 'rp-section__empty' }, COPY.none)); return out; }
386
+ const shown = inst.filter === 'needs'
387
+ ? v.docs.map((d) => ({ ...d, links: d.links.filter((l) => l.signal !== 'same' || l.syncing || inst.pending.has(keyOf(d, l))) })).filter((d) => d.links.length)
388
+ : v.docs;
389
+ if (!shown.length) { out.push(el('p', { class: 'rp-section__empty' }, COPY.nothingNeeds)); return out; }
390
+ out.push(...grouped(inst, shown));
391
+ return out;
392
+ }
393
+
394
+ function draw(inst) {
395
+ const root = inst.root;
396
+ if (!inst.ws) { root.replaceChildren(empty({ text: COPY.noRepo })); inst.last = 'none'; return; }
397
+ const fp = JSON.stringify([inst.loaded, inst.error, inst.view, inst.filter, inst.busy, inst.q, inst.files, inst.open, inst.adding, [...inst.pending.keys()]]);
398
+ if (fp === inst.last && root.childNodes.length && root.contains(inst.body)) return;
399
+ inst.last = fp;
400
+ const head = header(inst);
401
+ if (head.parentNode !== root || inst.body.parentNode !== root) root.replaceChildren(head, inst.body);
402
+ if (!inst.loaded) { inst.body.replaceChildren(el('p', { class: 'rp-section__empty' }, COPY.loading)); return; }
403
+ if (inst.error) { inst.body.replaceChildren(el('p', { class: 'rp-section__empty rp-section__empty--bad', role: 'alert' }, inst.error)); return; }
404
+ inst.body.replaceChildren(...body(inst));
405
+ }
@@ -48,6 +48,8 @@ export const DESTINATIONS = Object.freeze([
48
48
  Object.freeze({ id: 'initiatives', label: 'Initiatives', key: 'g i', module: 'dest-initiatives.js', run: 2 }),
49
49
  Object.freeze({ id: 'board', label: 'Board', key: 'g b', module: 'dest-board.js', run: 4 }),
50
50
  Object.freeze({ id: 'reports', label: 'Reports', key: 'g r', module: 'dest-reports.js', run: 5 }),
51
+ // Not a pm-os-absorb run (run: null): docs-three-way-sync added it — is each linked document the same as its copies.
52
+ Object.freeze({ id: 'docs', label: 'Docs', key: 'g d', module: 'dest-docs.js', run: null }),
51
53
  Object.freeze({ id: 'settings', label: 'Settings', key: null, module: 'dest-settings.js', run: 3 }),
52
54
  ]);
53
55
  export const EXTENSIONS = Object.freeze([
@@ -2904,6 +2904,20 @@
2904
2904
  "names": [
2905
2905
  "E3b: an outcome check recorded ONLY in planning/runs/ disposes its initiative"
2906
2906
  ]
2907
+ },
2908
+ {
2909
+ "id": "E72",
2910
+ "group": "E",
2911
+ "file": "workspace/d86-seams.test.js",
2912
+ "section": "assertion 7: the registry's destination modules",
2913
+ "change": "replaced",
2914
+ "slice": "docs-three-way-sync",
2915
+ "reason": "docs-three-way-sync adds a sixth destination, Docs (dest-docs.js), opened from ⌘K and g d. The assertion counted exactly five destination modules, so its name and count change.",
2916
+ "replacement": "The same assertion 7 under a new name counts six destination modules; the three extensions and provider-cards.js first are unchanged.",
2917
+ "pm_approval": null,
2918
+ "names": [
2919
+ "7 registry.js lists the five destination modules and the three extensions (provider-cards.js first)"
2920
+ ]
2907
2921
  }
2908
2922
  ]
2909
2923
  }
@@ -128,6 +128,12 @@ const profilesLib = require('./lib/profiles'); // readProfiles(homeDir)
128
128
  const focus = require('./lib/focus'); // buildFocus(input) → FocusView (cross-product)
129
129
  const handoff = require('./lib/handoff'); // readLinks(parsedFrontmatterObject) — never a path
130
130
  const fourPanel = require('./lib/four-panel'); // GET /api/initiative — the one-initiative join + the declaration stores
131
+ const docSyncStore = require('./lib/doc-sync-store');
132
+ // The session each repository's Check now started (workspace id → session id), so a second press while it runs refuses.
133
+ const docCheckSessions = new Map();
134
+ // The session each Use … version press started (workspace id|path|kind → { session, use }), so the line says "Updating…"
135
+ // before the session has opened its sync, and a second press on the same copy refuses.
136
+ const docSyncSessions = new Map();
131
137
  const planItems = require('./lib/plan-items.js'); // pm-os-absorb Run 1 — the ONE writer and fold of plan-items.jsonl (control graph)
132
138
  const planTree = require('./lib/plan-tree.js'); // …and the Breakdown layout: derived stages, owed rows, the Board columns
133
139
  const routeModules = require('./lib/route-modules.js'); // pm-os-absorb Runs 2–5 — each run's routes in its own lib/routes-<name>.js
@@ -6389,6 +6395,103 @@ async function handleRequest(req, res) {
6389
6395
  pushSessions();
6390
6396
  return json(res, { ok: true, id: s.id, initiative: next, attributed_by: s.attributedBy, previous });
6391
6397
  }
6398
+ // ── Docs: the sync signal for documents linked to Google Docs and Confluence (docs-three-way-sync) ──────────────
6399
+ // GET reads the links (the artifact-link store), the check readings and the files, and decides one signal per copy in
6400
+ // lib/doc-sync.js. The POSTs add or remove a link, or start the read-only `doc-check` skill as a session — the same
6401
+ // path a scheduled agent skill takes. No route here reads Google or Confluence: a session holding the PM's own
6402
+ // connectors does, and records what it read (D53). There is no sync and no schedule: the PM asked for a signal.
6403
+ if (p === '/api/docs' && req.method === 'GET') {
6404
+ const w = getWs(url.searchParams.get('ws'));
6405
+ const root = w ? w.root : SELF_ROOT;
6406
+ const v = docSyncStore.view(root);
6407
+ if (!v.ok) return json(res, v, 500);
6408
+ const alive = (id) => !!(id && sessions.get(id) && sessions.get(id).state !== 'exited');
6409
+ const sess = docCheckSessions.get(w ? w.id : '') || null;
6410
+ const live = alive(sess) ? sess : null;
6411
+ // Group by initiative: the library's own attribution of each file (pmos-shaped repositories only).
6412
+ const owner = new Map();
6413
+ try {
6414
+ if (wsShape(root) === 'pmos') {
6415
+ for (const g of initiativeIndex(root).initiatives) for (const a of g.artifacts) if (!owner.has(a.path)) owner.set(a.path, { id: g.id, title: g.title || g.id });
6416
+ }
6417
+ } catch { /* no grouping: every document is under Other */ }
6418
+ const wid = w ? w.id : '';
6419
+ const docs = v.docs.map((d) => ({
6420
+ ...d,
6421
+ initiative: owner.get(d.path) || (d.initiative_id ? { id: d.initiative_id, title: d.initiative_id } : null),
6422
+ links: d.links.map((l) => {
6423
+ if (l.syncing) return l;
6424
+ const ps = docSyncSessions.get(`${wid}|${d.path}|${l.kind}`);
6425
+ return ps && alive(ps.session) ? { ...l, syncing: { run: null, use: ps.use, started_at: ps.at } } : l;
6426
+ }),
6427
+ }));
6428
+ return json(res, { ...v, docs, ws: w ? w.id : null, session: live, connectors: fourPanel.connectors(os.homedir()) });
6429
+ }
6430
+ if (p === '/api/docs/files' && req.method === 'GET') {
6431
+ const w = getWs(url.searchParams.get('ws'));
6432
+ const root = w ? w.root : SELF_ROOT;
6433
+ const found = docSyncStore.findFiles(root, String(url.searchParams.get('q') || '').slice(0, 200));
6434
+ const v = docSyncStore.view(root);
6435
+ const linked = new Set(v.ok ? v.docs.map((d) => d.path) : []);
6436
+ return json(res, { ok: true, files: found.filter((f) => !linked.has(f.path)) });
6437
+ }
6438
+ if ((p === '/api/docs/link' || p === '/api/docs/unlink' || p === '/api/docs/check' || p === '/api/docs/sync') && req.method === 'POST') {
6439
+ let body = {}; try { body = JSON.parse((await readBody(req)) || '{}'); } catch { return json(res, { ok: false, error: 'invalid JSON body' }, 400); }
6440
+ const g = pmGuard.requirePagePress(req, body);
6441
+ if (!g.ok) return json(res, { ok: false, error: g.error }, g.status);
6442
+ const w = body.ws ? workspaces.get(String(body.ws)) : getWs(null);
6443
+ if (!w) return json(res, { ok: false, error: 'no such workspace' }, 404);
6444
+ if (p === '/api/docs/link') {
6445
+ const r = docSyncStore.addLink(w.root, { path: String(body.path || '').trim(), kind: body.kind, target: String(body.target || '').trim() });
6446
+ return json(res, r.ok ? { ok: true } : { ok: false, error: r.error }, r.ok ? 200 : 400);
6447
+ }
6448
+ if (p === '/api/docs/unlink') {
6449
+ const r = docSyncStore.removeLink(w.root, { path: String(body.path || ''), kind: body.kind });
6450
+ return json(res, r.ok ? { ok: true } : { ok: false, error: r.error }, r.ok ? 200 : 400);
6451
+ }
6452
+ // Use … version (amended 2026-10-06): one doc-sync session for one copy, in the direction the PM pressed. The page
6453
+ // holds the press for five seconds behind Undo before it posts here. The session writes one side with the PM's own
6454
+ // connectors and records it through `pmos docs sync-start|sync-finish|sync-fail`; nothing here touches a copy.
6455
+ if (p === '/api/docs/sync') {
6456
+ const rel = String(body.path || '').trim();
6457
+ const kind = String(body.kind || '');
6458
+ const use = String(body.use || '');
6459
+ if (!['pmos', 'copy'].includes(use)) return json(res, { ok: false, error: 'say which version to use: pmos or copy' }, 400);
6460
+ const v = docSyncStore.view(w.root);
6461
+ if (!v.ok) return json(res, v, 500);
6462
+ const doc = v.docs.find((d) => d.path === rel);
6463
+ const link = doc && doc.links.find((l) => l.kind === kind);
6464
+ if (!link) return json(res, { ok: false, error: `${rel} has no such copy` }, 404);
6465
+ const key = `${w.id}|${rel}|${kind}`;
6466
+ const prevSync = docSyncSessions.get(key);
6467
+ if (link.syncing || (prevSync && sessions.get(prevSync.session) && sessions.get(prevSync.session).state !== 'exited')) {
6468
+ return json(res, { ok: false, error: 'that copy is already being synced' }, 409);
6469
+ }
6470
+ const task = taskById('doc-sync', w.root);
6471
+ if (!task || !task.prompt) return json(res, { ok: false, error: 'the doc-sync skill is not installed' }, 500);
6472
+ const ask = `\n\nThis sync:\n- file: ${rel}\n- copy: ${kind} (${link.target})\n- use: ${use}`;
6473
+ const s = spawnSession({ name: 'Doc sync', agent: task.agent || 'claude', ws: w.id, origin: 'docs:sync',
6474
+ initialInput: task.prompt + ask + '\r', modelAsk: { skillTier: task.tier || null, skill: task.skill } });
6475
+ const started = s.state !== 'exited';
6476
+ if (started) docSyncSessions.set(key, { session: s.id, use, at: new Date().toISOString() });
6477
+ return json(res, { ok: started, session: s.id, error: started ? undefined : (s.note || 'could not start the session') }, started ? 200 : 500);
6478
+ }
6479
+ // Check now: one check per repository. A session already running, or a check the ledger still holds open, refuses.
6480
+ const prev = docCheckSessions.get(w.id);
6481
+ if (prev && sessions.get(prev) && sessions.get(prev).state !== 'exited') {
6482
+ return json(res, { ok: false, error: 'a check is already running', session: prev }, 409);
6483
+ }
6484
+ const v = docSyncStore.view(w.root);
6485
+ if (v.ok && v.open) return json(res, { ok: false, error: `a check is already running (started ${v.open.started_at})` }, 409);
6486
+ if (v.ok && !v.docs.length) return json(res, { ok: false, error: 'no document is linked yet — add a link first' }, 400);
6487
+ const task = taskById('doc-check', w.root);
6488
+ if (!task || !task.prompt) return json(res, { ok: false, error: 'the doc-check skill is not installed' }, 500);
6489
+ const s = spawnSession({ name: 'Doc check', agent: task.agent || 'claude', ws: w.id, origin: 'docs:check',
6490
+ initialInput: task.prompt + '\r', modelAsk: { skillTier: task.tier || null, skill: task.skill } });
6491
+ const started = s.state !== 'exited';
6492
+ if (started) docCheckSessions.set(w.id, s.id);
6493
+ return json(res, { ok: started, session: s.id, error: started ? undefined : (s.note || 'could not start the session') }, started ? 200 : 500);
6494
+ }
6392
6495
  if (p === '/api/initiative/link' && req.method === 'POST') {
6393
6496
  // Recording that an artifact corresponds to an external page. This writes a DECLARATION and
6394
6497
  // makes no request: PMOS holds no credential, so it can state what the PM says the mapping is
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: doc-check
3
+ label: "Check linked documents against Google Docs and Confluence"
4
+ description: "Read the Google Docs and Confluence copies of every linked document and record whether each is the same as the repository file. Read-only: it writes no document, page or file."
5
+ run: agent
6
+ agent: claude
7
+ output:
8
+ - Same
9
+ - Ahead or behind
10
+ - Edited in both
11
+ - Could not check
12
+ ---
13
+
14
+ /doc-check — read every Google Docs and Confluence copy of a linked document in THIS repository and record what you saw. PMOS then shows the PM, per document, whether the repository copy is the same as, ahead of or behind each copy.
15
+
16
+ The PM asked for a signal, not a sync. You read; you never write.
17
+
18
+ Rules that always hold:
19
+ 1. READ-ONLY EVERYWHERE. Never create, update, move, rename, comment on, share or delete a Google Doc, a Drive file or a Confluence page. Never edit, create or delete a file in this repository. Never commit or push.
20
+ 2. THE ONLY WRITES are `pmos docs check-start`, `pmos docs record` and `pmos docs check-finish`. Never write a `*.jsonl` file by hand.
21
+ 3. RECORD ONLY WHAT YOU READ. A version, a modified time or a title that you did not read from the copy is not recorded. If you cannot read a copy, record the error. Do not guess.
22
+ 4. USE THE PM'S CONNECTORS. Google Docs: the Google Drive tools (file metadata and file content). Confluence: the Atlassian tools (get the page, with its version). If a connector is missing or refuses, record that as the error for each of its copies and go on with the others.
23
+ 5. "SAME" MEANS THE SAME DOCUMENT. Compare the copy's text with the repository file's body as documents: the same headings, paragraphs, lists and tables, in the same order. Ignore formatting, whitespace, the YAML frontmatter of the repository file and the copy's title. If any sentence, item or row differs, it is not the same. If you cannot tell, it is not the same: set `same: false` and say why in `note`.
24
+
25
+ Procedure:
26
+ 1. Run `pmos docs check-start --json`. Keep `run` (the check id) and `links`. Each link has `path`, `kind` (google or confluence), `target` (the address), `remote_id` and `repo_hash`. If it says a check is already running, stop and report that.
27
+ 2. For each link, in order:
28
+ a. Read the copy's metadata. Google: the file's `modifiedTime`, its revision or version, who last modified it, and whether it is trashed. Confluence: the page's `version.number`, its last-modified time and who made that version.
29
+ b. If the copy does not exist or is in the trash, the reading is `{"path","kind","remote_id","exists":false}`.
30
+ c. If reading failed for any other reason (no access, no connector, an error), the reading is `{"path","kind","remote_id","error":"<the error, in one line>"}`.
31
+ d. Otherwise read the copy's content and the repository file `path` (skip this when `repo_hash` is null: the file is not in the repository). Decide `same` by rule 5. The reading is `{"path","kind","remote_id","exists":true,"version":"<version>","modified_at":"<ISO time>","modified_by":"<the person's name, when the copy says it>","title":"<the copy's title>","url":"<its address>","same":true|false,"repo_hash":"<repo_hash from step 1>","note":"<one line when same is false: the first difference>"}`.
32
+ 3. Write all readings as a JSON list to a file outside the repository (for example in /tmp) and run `pmos docs record <run> --file <that file>`. If a reading is refused, fix that reading from what you actually read and record it again; never invent a value to get it accepted.
33
+ 4. Run `pmos docs check-finish <run> --summary "<n> same, <n> ahead or behind, <n> edited in both, <n> could not check"`.
34
+ 5. Run `pmos docs list` and report it in the output sections below.
35
+
36
+ Output contract — produce EXACTLY these sections, the same shape every run, one short line per document and copy:
37
+ - Same
38
+ - Ahead or behind
39
+ - Edited in both
40
+ - Could not check
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: doc-sync
3
+ label: "Sync one linked document with its Google Docs or Confluence copy"
4
+ description: "Write one version of a linked document over the other, in the direction the PM pressed: the PMOS version to the copy, or the copy's version to the repository file. One copy, one side, nothing else."
5
+ run: agent
6
+ agent: claude
7
+ output:
8
+ - Written
9
+ - Not written
10
+ ---
11
+
12
+ /doc-sync — the PM pressed "Use … version" for ONE copy of ONE document in Docs. Write that version over the other side, record it, and stop. The lines after this procedure name the file, the copy and the version to use.
13
+
14
+ Rules that always hold:
15
+ 1. ONE COPY, ONE SIDE. With `use: pmos`, write the repository file's text to the copy, and change nothing in the repository. With `use: copy`, write the copy's text to the repository file, and change nothing in Google Docs or Confluence. Never write the other side, another copy, or another file.
16
+ 2. NEVER CREATE, MOVE, RENAME, SHARE OR DELETE. Update the existing Google Doc or Confluence page in place. If it does not exist or is in the trash, do not create it: record the sync as failed.
17
+ 3. KEEP THE FRONTMATTER. The repository file may start with a YAML frontmatter block (between two `---` lines). With `use: copy`, keep that block exactly as it is and replace only the text after it. With `use: pmos`, do not write the frontmatter to the copy.
18
+ 4. KEEP THE DOCUMENT. Convert between markdown and the copy's format faithfully: the same headings, paragraphs, lists, tables and links, in the same order. Do not add a summary, a note or a heading of your own.
19
+ 5. THE ONLY RECORDS are `pmos docs sync-start`, `pmos docs sync-finish` and `pmos docs sync-fail`. Never write a `*.jsonl` file by hand. Never commit or push: the PM's own Sync does that.
20
+ 6. USE THE PM'S CONNECTORS. Google Docs: the Google Drive tools. Confluence: the Atlassian tools. If the PM is asked to allow the write, that is expected; wait for their answer. If the connector is missing or refuses, record the sync as failed with the error.
21
+
22
+ Procedure:
23
+ 1. Run `pmos docs sync-start <file> <google|confluence> --use <pmos|copy> --json` with the values below. Keep `run`, `target` and `remote_id`. If it is refused (for example, the copy is already being synced), stop and report the reason.
24
+ 2. Read the copy (its content, its version and its last-modified time) and the repository file.
25
+ 3. Write, by rule 1 to rule 4:
26
+ - `use: pmos`: update the copy's content with the repository file's body. Confluence: update the page with its next version number and keep its title. Google Docs: replace the document's content.
27
+ - `use: copy`: replace the repository file's body with the copy's content, converted to markdown. Keep the frontmatter.
28
+ 4. Read the copy again and take its version and last-modified time AFTER the write (with `use: copy` the copy did not change; take what you read in step 2).
29
+ 5. Run `pmos docs sync-finish <run> --version "<version>" --modified-at "<ISO time>" --url "<the copy's address>"`.
30
+ 6. If any step failed, run `pmos docs sync-fail <run> --reason "<the error, in one line>"` instead of step 5, and say what was and was not written.
31
+
32
+ Output contract — produce EXACTLY these sections, one short line each:
33
+ - Written
34
+ - Not written
@@ -0,0 +1,6 @@
1
+ {
2
+ "_comment": "docs-three-way-sync: the sync signal for documents linked to Google Docs and Confluence.",
3
+ "modules": ["doc-sync.test.js", "docs-cli.test.js"],
4
+ "panels": ["pm-docs-test.js"],
5
+ "seconds": { "doc-sync.test.js": 1, "docs-cli.test.js": 6, "pm-docs-test.js": 60 }
6
+ }