create-pmos 0.55.5 → 0.57.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/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/REMOTE.md +38 -3
- package/workspace/guide/docs.md +96 -0
- package/workspace/guide/index.md +3 -1
- package/workspace/guide/layout.md +2 -1
- package/workspace/lib/access-jwt.js +66 -0
- package/workspace/lib/access-keys.js +104 -0
- package/workspace/lib/doc-sync-store.js +322 -0
- package/workspace/lib/doc-sync.js +225 -0
- package/workspace/lib/docs-cli.js +221 -0
- package/workspace/lib/online-mode.js +158 -0
- package/workspace/lib/pmos-verbs.js +3 -0
- package/workspace/lib/remote-access.js +5 -3
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/app.js +2 -2
- package/workspace/public/pm/components.css +146 -0
- package/workspace/public/pm/dest-docs.js +405 -0
- package/workspace/public/pm/dest-settings.js +56 -3
- package/workspace/public/pm/frame-copy.js +19 -0
- package/workspace/public/pm/registry.js +2 -0
- package/workspace/replaced-tests.json +25 -0
- package/workspace/server.js +215 -9
- package/workspace/skills/doc-check.skill +40 -0
- package/workspace/skills/doc-sync.skill +34 -0
- package/workspace/test-lists/docs-three-way-sync.json +6 -0
- package/workspace/test-lists/workspace-online-mode.json +5 -0
|
@@ -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
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/* pm/dest-settings.js — the Settings destination, the frame's ⚙ (pm-os-absorb Run 3, slice 3A; PRD R9).
|
|
2
2
|
*
|
|
3
|
-
* Settings holds settings only, in
|
|
3
|
+
* Settings holds settings only, in six sections and in this order:
|
|
4
4
|
* Repositories every registered repository as a row (open it to make it current; ⋯ for more), and Add repository,
|
|
5
5
|
* which draws the existing add-repository flow (view-addrepo.js) in place
|
|
6
6
|
* Profiles one row that opens the Claude profiles in the detail
|
|
7
7
|
* New sessions the default CLI and the default model the dock's + Session applies (dock.js)
|
|
8
8
|
* Validate consent the current repository's consent switch (GET and POST /api/validate/repo)
|
|
9
|
+
* Online the Online switch and its five settings (GET and POST /api/online; workspace-online-mode, D89)
|
|
9
10
|
* Theme Dark or Light
|
|
10
11
|
* The work itself (what needs you, initiatives, items, reports) is never here.
|
|
11
12
|
*
|
|
@@ -26,6 +27,7 @@ import { COPY } from './frame-copy.js';
|
|
|
26
27
|
const P = COPY.page;
|
|
27
28
|
const roots = new WeakMap(); // host -> { root, sig }
|
|
28
29
|
const consentByWs = new Map(); // ws -> { on: boolean | null, error, at, pending }
|
|
30
|
+
const online = { data: null, error: null, note: null, at: 0, pending: false }; // GET /api/online, and the last refusal
|
|
29
31
|
let addOpen = false;
|
|
30
32
|
let addMod = null;
|
|
31
33
|
|
|
@@ -52,11 +54,29 @@ async function setConsent(ws, on) {
|
|
|
52
54
|
setState({});
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
function readOnline(force) {
|
|
58
|
+
if (FIXTURE || online.pending) return;
|
|
59
|
+
if (!force && Date.now() - online.at < 10000) return;
|
|
60
|
+
online.pending = true;
|
|
61
|
+
pmGet('/api/online').then((j) => {
|
|
62
|
+
online.pending = false; online.at = Date.now();
|
|
63
|
+
if (j && j.ok !== false) { online.data = j; online.error = null; } else online.error = P.onlineUnread;
|
|
64
|
+
setState({});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function setOnline(patch) {
|
|
69
|
+
const j = await pmPost('/api/online', patch);
|
|
70
|
+
if (j && j.ok !== false) { online.data = j; online.note = null; } else online.note = (j && j.error) || P.onlineUnread;
|
|
71
|
+
online.at = Date.now();
|
|
72
|
+
setState({});
|
|
73
|
+
}
|
|
74
|
+
|
|
55
75
|
const section = (id, title, kids, action) => el('section', { class: 'fr-set__section', 'data-fr-section': id },
|
|
56
76
|
el('div', { class: 'fr-set__head' }, el('h2', { class: 'fr-set__title' }, title), action || null),
|
|
57
77
|
kids);
|
|
58
78
|
|
|
59
|
-
/* ── the
|
|
79
|
+
/* ── the six sections ───────────────────────────────────────────────────────────────────────────────────────────── */
|
|
60
80
|
|
|
61
81
|
function reposSection(scope) {
|
|
62
82
|
const tuples = repoTuples();
|
|
@@ -166,6 +186,38 @@ function validateSection(scope) {
|
|
|
166
186
|
el('span', { 'data-fr': 'consent-state', 'data-on': known ? String(c.on) : 'unread' }, line), toggle, open)]);
|
|
167
187
|
}
|
|
168
188
|
|
|
189
|
+
function onlineSection() {
|
|
190
|
+
readOnline(false);
|
|
191
|
+
const d = online.data;
|
|
192
|
+
if (!d) return section('online', P.online, [el('p', { class: 'fr-set__note' }, online.error || '')]);
|
|
193
|
+
if (d.envHost) return section('online', P.online, [el('p', { class: 'fr-set__note', 'data-fr': 'online-state', 'data-on': 'env' }, P.onlineEnv(d.envHost))]);
|
|
194
|
+
const lines = [el('p', { class: 'fr-set__note', 'data-fr': 'online-state', 'data-on': String(!!d.on) }, d.on ? P.onlineOn(d.host) : P.onlineOff)];
|
|
195
|
+
if (d.on) {
|
|
196
|
+
const r = d.running || {};
|
|
197
|
+
lines.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-tunnel' },
|
|
198
|
+
r.running ? P.onlineRunning : r.lastExit ? P.onlineExited(r.lastExit.code) : P.onlineStarting));
|
|
199
|
+
if (d.keys && !d.keys.keys) lines.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-keys' }, d.keys.lastError ? P.onlineKeysError(d.keys.lastError) : P.onlineKeysWait));
|
|
200
|
+
}
|
|
201
|
+
if (!d.canChange) return section('online', P.online, [...lines, el('p', { class: 'fr-set__note' }, P.onlineRemote)]);
|
|
202
|
+
const f = (key, label, placeholder) => field({
|
|
203
|
+
label, value: d[key] || '', placeholder,
|
|
204
|
+
onCommit: (v) => setOnline({ [key]: v }),
|
|
205
|
+
attrs: { 'data-fr': 'online-' + key, disabled: d.on ? true : undefined },
|
|
206
|
+
});
|
|
207
|
+
const fields = el('div', { class: 'fr-set__fields' },
|
|
208
|
+
f('host', P.onlineHost, 'pmos.example.com'), f('tunnel', P.onlineTunnel, 'pmos'), f('team', P.onlineTeam, 'myteam.cloudflareaccess.com'),
|
|
209
|
+
f('audience', P.onlineAudience, ''), f('email', P.onlineEmail, 'you@example.com'));
|
|
210
|
+
const blocked = !d.on && (!d.complete ? P.onlineNeedFields : !d.cloudflared ? P.onlineNoCloudflared : null);
|
|
211
|
+
const toggle = button({
|
|
212
|
+
label: d.on ? P.turnOff : P.turnOn, variant: 'outline', size: 'sm', disabled: !!blocked,
|
|
213
|
+
attrs: { 'data-fr': 'online-toggle' }, onClick: () => setOnline({ on: !d.on }),
|
|
214
|
+
});
|
|
215
|
+
const kids = [el('p', { class: 'fr-set__note' }, P.onlineIntro), fields, el('div', { class: 'fr-set__line' }, ...lines, toggle)];
|
|
216
|
+
if (blocked) kids.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-blocked' }, blocked));
|
|
217
|
+
if (online.note) kids.push(el('p', { class: 'fr-set__note', 'data-fr': 'online-error', role: 'alert' }, online.note));
|
|
218
|
+
return section('online', P.online, kids);
|
|
219
|
+
}
|
|
220
|
+
|
|
169
221
|
function themeSection() {
|
|
170
222
|
return section('theme', P.theme, [tabs({
|
|
171
223
|
variant: 'segmented', role: 'radiogroup', ariaLabel: P.theme, value: state.theme,
|
|
@@ -182,7 +234,7 @@ function signature(scope) {
|
|
|
182
234
|
const addKeys = addOpen ? Object.keys(state).filter((k) => /^add[A-Z]/.test(k)).sort().map((k) => [k, state[k]]) : null;
|
|
183
235
|
let add = '';
|
|
184
236
|
try { add = JSON.stringify(addKeys); } catch { add = String(Date.now()); }
|
|
185
|
-
return JSON.stringify([repoTuples(), activeProduct(), scope, state.theme, sessionDefaults(), ws, consentByWs.get(ws) || null, addOpen, !!addMod]) + add;
|
|
237
|
+
return JSON.stringify([repoTuples(), activeProduct(), scope, state.theme, sessionDefaults(), ws, consentByWs.get(ws) || null, addOpen, !!addMod, online.data, online.error, online.note]) + add;
|
|
186
238
|
}
|
|
187
239
|
|
|
188
240
|
/** True while the PM is typing in the page or a menu of it is open: a rebuild then would take the focus away. */
|
|
@@ -208,6 +260,7 @@ export function mountDestination(host, ctx) {
|
|
|
208
260
|
profilesSection(),
|
|
209
261
|
sessionsSection(),
|
|
210
262
|
validateSection(scope),
|
|
263
|
+
onlineSection(),
|
|
211
264
|
themeSection(),
|
|
212
265
|
]);
|
|
213
266
|
}
|
|
@@ -150,6 +150,25 @@ export const COPY = {
|
|
|
150
150
|
theme: 'Theme',
|
|
151
151
|
dark: 'Dark',
|
|
152
152
|
light: 'Light',
|
|
153
|
+
online: 'Online',
|
|
154
|
+
onlineIntro: 'Reach this workspace from your phone at your own address. The workspace runs the Cloudflare tunnel, and you sign in with Cloudflare Access (Google or an email code). Only this computer can change it.',
|
|
155
|
+
onlineHost: 'Public address',
|
|
156
|
+
onlineTunnel: 'Tunnel name',
|
|
157
|
+
onlineTeam: 'Cloudflare team domain',
|
|
158
|
+
onlineAudience: 'Access audience (AUD) tag',
|
|
159
|
+
onlineEmail: 'Email allowed in',
|
|
160
|
+
onlineOn: (host) => `Online at https://${host}/`,
|
|
161
|
+
onlineOff: 'Offline. Only this computer can reach the workspace.',
|
|
162
|
+
onlineRunning: 'The tunnel is running.',
|
|
163
|
+
onlineStarting: 'The tunnel is starting.',
|
|
164
|
+
onlineExited: (code) => `The tunnel stopped${code == null ? '' : ` (exit code ${code})`}. It starts again in a few seconds. Its log is online-tunnel.log in the workspace state folder.`,
|
|
165
|
+
onlineKeysWait: 'Cloudflare\'s sign-in keys are not loaded yet. Sign-ins are refused until they are.',
|
|
166
|
+
onlineKeysError: (e) => `Cloudflare\'s sign-in keys did not load (${e}). Check the team domain. Sign-ins are refused until they load.`,
|
|
167
|
+
onlineNeedFields: 'Fill in all five fields to turn Online on.',
|
|
168
|
+
onlineNoCloudflared: 'cloudflared is not installed. Install it (brew install cloudflared), then turn Online on.',
|
|
169
|
+
onlineEnv: (host) => `Online at https://${host}/, set by PMOS_PUBLIC_HOST when the workspace started. It signs in with the token. Restart the workspace without PMOS_PUBLIC_HOST to use this switch.`,
|
|
170
|
+
onlineRemote: 'Only the computer that runs the workspace can change this.',
|
|
171
|
+
onlineUnread: 'The Online settings did not load.',
|
|
153
172
|
},
|
|
154
173
|
};
|
|
155
174
|
|
|
@@ -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([
|
|
@@ -69,6 +69,17 @@
|
|
|
69
69
|
"names": [
|
|
70
70
|
"(a) calibration: the audit on specimen A gives exactly Today 17, Initiatives 24, Board 33, Reports 14 outside the dock"
|
|
71
71
|
]
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"id": "SOM-D89",
|
|
75
|
+
"file": "workspace/no-network.test.js",
|
|
76
|
+
"run": "workspace-online-mode",
|
|
77
|
+
"ref": "D89 (ratified by PM wawan on a decision card, 2026-10-06) and planning/prd/workspace-online-mode-prd.md O5",
|
|
78
|
+
"reason": "D89 adds a second named HTTP client, lib/access-keys.js (a GET of the PM's Cloudflare Access team's public key set while Online is on). The two assertions that named lib/typesafe.js as the only client now name both sanctioned modules; three new assertions pin the new module to cloudflareaccess.com, GET only, and its two requirers. The D79 assertions are unchanged.",
|
|
79
|
+
"names": [
|
|
80
|
+
"no-network: the server process opens no HTTP client outside the one TypeSafe module (D79)",
|
|
81
|
+
"no-network: lib/typesafe.js is the ONLY file that opens an HTTP client"
|
|
82
|
+
]
|
|
72
83
|
}
|
|
73
84
|
],
|
|
74
85
|
"entries": [
|
|
@@ -2904,6 +2915,20 @@
|
|
|
2904
2915
|
"names": [
|
|
2905
2916
|
"E3b: an outcome check recorded ONLY in planning/runs/ disposes its initiative"
|
|
2906
2917
|
]
|
|
2918
|
+
},
|
|
2919
|
+
{
|
|
2920
|
+
"id": "E72",
|
|
2921
|
+
"group": "E",
|
|
2922
|
+
"file": "workspace/d86-seams.test.js",
|
|
2923
|
+
"section": "assertion 7: the registry's destination modules",
|
|
2924
|
+
"change": "replaced",
|
|
2925
|
+
"slice": "docs-three-way-sync",
|
|
2926
|
+
"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.",
|
|
2927
|
+
"replacement": "The same assertion 7 under a new name counts six destination modules; the three extensions and provider-cards.js first are unchanged.",
|
|
2928
|
+
"pm_approval": null,
|
|
2929
|
+
"names": [
|
|
2930
|
+
"7 registry.js lists the five destination modules and the three extensions (provider-cards.js first)"
|
|
2931
|
+
]
|
|
2907
2932
|
}
|
|
2908
2933
|
]
|
|
2909
2934
|
}
|