create-pmos 0.188.0 → 0.189.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 (32) 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/c3-report-setup-test.js +1 -1
  7. package/workspace/c3-scan-fixture.js +247 -5
  8. package/workspace/c3-scan-test.js +453 -106
  9. package/workspace/guide/horizon-scan.md +45 -36
  10. package/workspace/lib/headless.js +19 -5
  11. package/workspace/lib/pmos-verbs.js +2 -2
  12. package/workspace/lib/route-modules.js +1 -1
  13. package/workspace/lib/routes-boards.js +29 -12
  14. package/workspace/lib/routes-scan.js +224 -44
  15. package/workspace/lib/scan-cli.js +143 -45
  16. package/workspace/lib/scan-run.js +242 -87
  17. package/workspace/lib/scan.js +623 -79
  18. package/workspace/package-lock.json +2 -2
  19. package/workspace/package.json +1 -1
  20. package/workspace/public/pm/c3-doc-sheet.js +7 -1
  21. package/workspace/public/pm/c3-initiatives.js +5 -4
  22. package/workspace/public/pm/c3-scan-api.js +167 -0
  23. package/workspace/public/pm/c3-scan-next.js +248 -0
  24. package/workspace/public/pm/c3-scan-setup.js +317 -0
  25. package/workspace/public/pm/c3-scan-words.js +34 -0
  26. package/workspace/public/pm/c3-scan.css +58 -9
  27. package/workspace/public/pm/c3-scan.js +36 -208
  28. package/workspace/public/pm/c3-toast.js +1 -0
  29. package/workspace/server.js +7 -4
  30. package/workspace/skills/horizon-scan-repo.skill +26 -10
  31. package/workspace/skills/horizon-scan-research.skill +44 -0
  32. package/workspace/skills/horizon-scan-web.skill +12 -5
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.188.0",
3
+ "version": "0.189.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.188.0",
9
+ "version": "0.189.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.188.0",
3
+ "version": "0.189.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -251,6 +251,11 @@ const olGet = () => { try { return localStorage.getItem(OL_KEY) !== 'off'; } cat
251
251
  const olSet = (on) => { try { localStorage.setItem(OL_KEY, on ? 'on' : 'off'); } catch { /* no storage here */ } };
252
252
  const ESTATE = { ok: 'in step', drift: 'out of step', conflict: 'conflict', missing: 'missing', ahead: 'PMOS ahead' };
253
253
 
254
+ /** Parts another module puts under the sheet's header and tabs, ahead of the text (the Horizon scan's "Proposed next steps"). Each is
255
+ * ({ ws, doc, text, key }) => Node|null, called on every draw with the text read so far; `key` is stable for one open sheet, so a part
256
+ * keeps its node (and its own state) in a WeakMap on it. A part that throws is left out. */
257
+ export const docAbove = [];
258
+
254
259
  export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, onChanged, generated, secExtra, library, openGraph, report }) {
255
260
  const S = { mode: MODE[mode] || mode, prompt, text: null, err: '', revs: null, texts: {}, health: null, hAsk: false, busy: false, changed: false,
256
261
  meta: null, metaFor: null, props: null, propsErr: '', log: null, logErr: '', asking: null, closed: false, who: false, src: false, preview: {},
@@ -1115,7 +1120,8 @@ export function openDocSheet({ ws, doc, mode = 'read', prompt = '', relations, o
1115
1120
  const body = S.mode === 'read' ? readBody() : S.mode === 'edit' ? editBody() : S.mode === 'ask' ? propsBody() : S.mode === 'hist' ? logBody() : S.mode === 'health' ? healthBody() : linksBody();
1116
1121
  const keep = ui.body.querySelector('textarea:focus,input[type=text]:focus');
1117
1122
  const had = keep ? { id: keep.id, s: keep.selectionStart } : null;
1118
- ui.body.replaceChildren(el('div', { class: 'dpn ds' }, top(), copiesPanel(), body));
1123
+ const above = docAbove.map((fn) => { try { return fn({ ws, doc, text: S.text, key: S }); } catch { return null; } });
1124
+ ui.body.replaceChildren(el('div', { class: 'dpn ds' }, top(), above, copiesPanel(), body));
1119
1125
  const sh = ui.body.closest('.sheet'); if (sh) sh.classList.toggle('dwx', S.mode === 'read' && S.ol && !!ui.body.querySelector('.ds-ol'));
1120
1126
  const sb = ui.body.closest('.sb');
1121
1127
  if (sb && !sb.__dsTrack) { sb.__dsTrack = true; sb.addEventListener('scroll', trackCur, { passive: true }); }
@@ -49,8 +49,8 @@ import { TEMPLATES, BOARD_TEMPLATES, isBuildStage, templateBuilds, offered, wfOf
49
49
  import { timeline, tlInit, tlRange } from './c3-ini-timeline.js';
50
50
  import { RECORD_META, recordToolsFor } from './c3-ini-records.js';
51
51
  import { agentSetting, loadAgentMode, MODES as AGENT_MODES } from './c3-driver.js';
52
+ import { scanPortfolioSection, scanPointer } from './c3-scan-setup.js';
52
53
  import { lifeBanner, pageMenu, ensureLifeCss } from './c3-ini-life.js';
53
- import { scanSetting, takeSettingsFor } from './c3-scan.js';
54
54
 
55
55
  export { btn, geo };
56
56
  export { TEMPLATES, CATS };
@@ -256,6 +256,8 @@ export function mountInitiatives(pane, opts) {
256
256
  pbtn('primary', 'New board', 'plus', { 'data-ini': 'new-board', onclick: () => openNewBoard() }))),
257
257
  el('div', { class: 'calbody', 'data-ini': 'portfolio' },
258
258
  lh('cols', sentenceOf(bad, warn), PORTFOLIO + ': boards hold initiatives, initiatives hold epics, epics hold stories, tasks and bugs. Stages roll up into Discover, Plan, Do and Learn so boards compare.'),
259
+ // The Horizon scan runs for the whole portfolio (c3-scan-setup.js): its section is here, with Scan now always visible. Not in demo mode.
260
+ opts.demo ? null : scanPortfolioSection({ ws: opts.ws || (boards[0] && boards[0].ws) || '', boardCount: boards.length, showTab: opts.showTab }),
259
261
  cols(left, right),
260
262
  sect({ ic: 'cal', title: 'Timeline across boards', right: mono('Board > initiative > epic') }, tlHost)));
261
263
  /* the cross-board timeline: board > the initiatives that are featured, at risk or carry work > their epics */
@@ -328,7 +330,6 @@ export function mountInitiatives(pane, opts) {
328
330
  segTabs(tabs, tab, (k) => openBoard(b.id, k), 'Board views', { 'data-ini': 'board-tabs' }),
329
331
  host));
330
332
  mountTab(host, tab === 'summary' ? mountSummary : null, (BOARD_TAB_LIST.find((t) => t[0] === tab) || [])[1], makeCtx(b, { tab, filter: P.loc.filter || {} }), token);
331
- if (!opts.demo && takeSettingsFor(b.id)) openBoardSettings(b); // "Scan settings" on the Briefing's Horizon scan card
332
333
  }
333
334
 
334
335
  /* Summary: initiatives at a glance, where they stand, what needs attention */
@@ -392,8 +393,8 @@ export function mountInitiatives(pane, opts) {
392
393
  loadItems(makeCtx(b, {})).then((r) => { usage = r; render(); });
393
394
  // The Agent setting (c3-driver.js): read once, and saved at once when a mode is pressed (POST /api/driver/mode). Not in demo mode.
394
395
  const A = { mode: null, err: '', busy: false };
395
- // The Horizon scan block (c3-scan.js): one node that reads and presses by itself, so a redraw of this form keeps it. Not in demo mode.
396
- const scanNode = opts.demo ? null : scanSetting({ api: apiFor(b.ws), board: b.id, boardName: b.name });
396
+ // The Horizon scan runs for the whole portfolio: Board settings holds only a pointer to its setup (c3-scan-setup.js). Not in demo mode.
397
+ const scanNode = opts.demo ? null : scanPointer(b.ws || opts.ws || '');
397
398
  if (!opts.demo) loadAgentMode(apiFor(b.ws), b.id).then((r) => { if (r.ok) A.mode = r.mode; else A.err = r.error; render(); });
398
399
  async function pickAgent(mode) {
399
400
  if (A.busy || mode === A.mode) return;
@@ -0,0 +1,167 @@
1
+ /* pm/c3-scan-api.js — every call the portfolio Horizon scan makes to the server, in ONE place (initiative horizon-scan-portfolio, PRD section 7).
2
+ * The pages never write a scan route name: they call a function here. If the server names a route or a field differently, change it here only.
3
+ *
4
+ * GET /api/scan/portfolio?ws getPortfolio(ws) the setting, watch list, last and next scan, open scan, reports, proposals
5
+ * GET /api/scan/report?ws&id getReport(ws, id) { report, markdown, body, written, proposals }
6
+ * POST /api/scan/watch postWatch(ws, body) { ws, op: keep|edit|remove|add, id?, name?, kind?, why?, boards?, restore? }
7
+ * POST /api/scan/setting postSetting(ws, body) { ws, cadence?, repo?, types? } types is an array of type keys
8
+ * POST /api/scan/now postNow(ws, sel) { ws, items: [id], repo, types: [key] }
9
+ * POST /api/scan/stop postStop(ws) { ws }
10
+ * POST /api/scan/proposal postProposal(ws, body) { ws, id, action: approve|change|dismiss|undo, fields?, why? } undo takes back a dismissal or a change, never an approval
11
+ *
12
+ * The page sends no `by`: the server takes the PM from the call path (http.js adds the nonce and the page Origin).
13
+ * The answers are read into one shape (readPortfolio): a field the server leaves out reads as empty, never as a thrown error.
14
+ * The names are the server's (lib/routes-scan.js header): items, proposals_waiting, open.scan, steps[].id, proposal.finding, dismissed_why, result.
15
+ * Pure parts (Node-testable): readPortfolio, readProposal, readOut, evidenceKind, scanNowWords, typeKeys, proposalFields, frontOf.
16
+ */
17
+ import { pmGet, pmPost } from './http.js';
18
+
19
+ export const ROUTES = Object.freeze({
20
+ portfolio: '/api/scan/portfolio', report: '/api/scan/report', watch: '/api/scan/watch', setting: '/api/scan/setting',
21
+ now: '/api/scan/now', stop: '/api/scan/stop', proposal: '/api/scan/proposal',
22
+ });
23
+
24
+ /** The report types, in the order the sheets show them. [key, name, help, repository only]. */
25
+ export const TYPES = [
26
+ ['benchmark', 'Benchmark report', 'Compares our flow with named products, point by point.', false],
27
+ ['competitive', 'Competitive analysis', 'Positioning, features, pricing signals, threats and openings.', false],
28
+ ['insight', 'Insight report', 'Themes across boards from Jira, friction, notes and Slack, plus web trends.', false],
29
+ ['productivity', 'Productivity report', 'Delivery health from the repository and Jira. No web.', true],
30
+ ];
31
+ export const TYPE_KEYS = TYPES.map((t) => t[0]);
32
+ export const typeName = (k) => (TYPES.find((t) => t[0] === k) || [0, k])[1];
33
+ export const KINDS = ['product', 'domain', 'approach'];
34
+
35
+ /** A types answer as a list of keys: an array of keys, or an object of key to on/off. */
36
+ export function typeKeys(v) {
37
+ if (Array.isArray(v)) return TYPE_KEYS.filter((k) => v.includes(k));
38
+ if (v && typeof v === 'object') return TYPE_KEYS.filter((k) => v[k]);
39
+ return TYPE_KEYS.slice();
40
+ }
41
+ /** The kind of an evidence pointer: a public page (http or https), a Jira key, a path in the repository, or a note. The backend keeps only { label, ref }. */
42
+ export const evidenceKind = (ref) => {
43
+ const r = String(ref || '').trim();
44
+ return /^https?:\/\//i.test(r) ? 'web' : /^[A-Z][A-Z0-9_]+-\d+$/.test(r) ? 'jira' : /^[\w.-]+(\/[\w.@-]+)+$/.test(r) ? 'repo' : 'note';
45
+ };
46
+ /** The fields of a proposal under the backend's names, each as a string: create { title, board, lane, anchor, intent }, research { question, depth },
47
+ * attach { initiative }. `title` is the proposal's own title (a change edits it through `fields.title`). */
48
+ export function proposalFields(kind, f, title) {
49
+ const o = f && typeof f === 'object' ? f : {};
50
+ const str = (v) => (v == null ? '' : String(v));
51
+ if (kind === 'create') return { title: str(title), board: str(o.board), lane: str(o.lane), anchor: str(o.anchor), intent: str(o.intent) };
52
+ if (kind === 'research') return { question: str(o.question), depth: str(o.depth) || 'standard' };
53
+ return { initiative: str(o.initiative) };
54
+ }
55
+ /** What an approved proposal made or started, from a proposal's `result` (or the answer of the approve press): { id, board, stage, scan, report }. The initiative is a
56
+ * string on a stored proposal and an object in the approve answer. Null when there is none. */
57
+ export function readOut(res) {
58
+ if (!res || typeof res !== 'object') return null;
59
+ const i = res.initiative;
60
+ const id = typeof i === 'string' ? i : i && i.id ? String(i.id) : '';
61
+ return { id, board: String(res.board || ''), stage: String(res.stage || ''), scan: String(res.scan || ''), report: String(res.report || '') };
62
+ }
63
+ /** One proposal as the page uses it: the backend's fields, `from` (the finding it came from), `reason` (the dismissal), `out` (what approve made) and a state of
64
+ * open | done (approved) | dismissed. A research proposal that is approved while its scan is still open reads `running` (getReport sets that). */
65
+ export function readProposal(p) {
66
+ const kind = ['create', 'research', 'attach'].includes(p.kind) ? p.kind : 'create';
67
+ const state = p.state === 'approved' ? 'done' : p.state === 'dismissed' ? 'dismissed' : 'open';
68
+ return { id: String(p.id), report: String(p.report || ''), reportTitle: p.report_title || '', kind, title: p.title || '', why: p.why || '', from: p.finding || '',
69
+ evidence: (Array.isArray(p.evidence) ? p.evidence : []).map((e) => ({ label: String((e && e.label) || ''), ref: String((e && e.ref) || ''), kind: evidenceKind(e && e.ref) })).filter((e) => e.ref),
70
+ fields: proposalFields(kind, p.fields, p.title), state, changed: !!p.changed, reason: p.dismissed_why || '', out: readOut(p.result), steps: null, at: p.at || '', decidedAt: p.decided_at || '' };
71
+ }
72
+
73
+ /** The plain words for a refused Scan now: 409 running or quiet hours, 429 manual-limit. The server's own sentence is the fallback. */
74
+ export function scanNowWords(r) {
75
+ const why = r && r.why;
76
+ if (why === 'running') return 'A scan is running now. Wait for it to end, or stop it.';
77
+ if (why === 'quiet-hours') return 'Quiet hours hold, so a manual scan does not start now. Try again when they end.';
78
+ if (why === 'manual-limit') return 'You used all ' + ((r && r.limit) || 3) + ' manual scans today. The scheduled scan still runs. Try again tomorrow.';
79
+ return (r && r.error) || 'the workspace did not answer';
80
+ }
81
+ const readManual = (m) => ({ limit: Number(m && m.limit) || 3, used: Number(m && m.used) || 0, left: m && m.left != null ? Number(m.left) : 3, ok: !m || m.ok !== false, why: (m && m.why) || '', error: (m && m.error) || '' });
82
+
83
+ /** The portfolio answer in one shape. The names are the server's: items (suggested | kept), proposals_waiting as `waiting`, open.scan, steps[].id. */
84
+ export function readPortfolio(r) {
85
+ const s = (r && r.setting) || {};
86
+ const o = r && r.open;
87
+ return {
88
+ ok: !!(r && r.ok !== false), error: r && r.ok === false ? r.error || '' : '',
89
+ setting: { cadence: ['daily', 'weekly', 'off'].includes(s.cadence) ? s.cadence : 'daily', repo: s.repo !== false, types: typeKeys(s.types) },
90
+ items: (Array.isArray(r && r.items) ? r.items : []).map((w) => ({ id: String(w.id), kind: KINDS.includes(w.kind) ? w.kind : 'product', name: w.name || '', why: w.why || '',
91
+ boards: Array.isArray(w.boards) ? w.boards.map(String) : [], ini: w.ini || '', added: !!w.added, state: w.state === 'kept' ? 'kept' : 'suggested' })),
92
+ last: r && r.last ? { ...r.last } : null, next: (r && r.next) || '', manual: readManual(r && r.manual), switchedOff: !!(r && r.switched_off),
93
+ open: o ? { scan: String(o.scan || ''), mode: o.mode || 'scan', stopping: !!o.stopping, steps: (Array.isArray(o.steps) ? o.steps : []).map((x) => ({ id: String(x.id || ''), label: x.label || x.id || '', state: x.state || 'pending' })),
94
+ reports: Array.isArray(o.reports) ? o.reports : [], research: o.research || null } : null,
95
+ reports: (Array.isArray(r && r.reports) ? r.reports : []).map((x) => ({ id: String(x.id), type: x.type || '', label: x.label || '', title: x.title || '', path: x.path || '', scan: x.scan || '', at: x.at || '', parent: x.parent || '',
96
+ written: x.written === true, origin: x.origin || '', open: Number(x.proposals && x.proposals.open) || 0, total: Number(x.proposals && x.proposals.total) || 0 })),
97
+ waiting: Number(r && r.proposals_waiting) || 0,
98
+ proposals: (Array.isArray(r && r.proposals) ? r.proposals : []).map(readProposal),
99
+ boards: Array.isArray(r && r.boards) ? r.boards : null,
100
+ };
101
+ }
102
+
103
+ /** The front matter of a markdown text as { key: value }, or {} when it has none. */
104
+ export function frontOf(text) {
105
+ const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text || ''));
106
+ const out = {};
107
+ if (!m) return out;
108
+ for (const line of m[1].split(/\r?\n/)) { const k = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line); if (k) out[k[1]] = k[2].replace(/^["']|["']$/g, '').trim(); }
109
+ return out;
110
+ }
111
+
112
+ /* ── reads ── */
113
+ const fail = (e) => ({ ok: false, error: (e && e.message) || 'the workspace did not answer' });
114
+ const q = (o) => new URLSearchParams(o).toString();
115
+ export async function getPortfolio(ws) {
116
+ const r = await pmGet(ROUTES.portfolio + '?' + q({ ws })).catch(fail);
117
+ return readPortfolio(r);
118
+ }
119
+ /** A report: { report, markdown, body, written, proposals }. A research proposal that was approved while its scan is still open reads `running`, with the steps of that scan. */
120
+ export async function getReport(ws, id) {
121
+ const r = await pmGet(ROUTES.report + '?' + q({ ws, id })).catch(fail);
122
+ if (!r || r.ok === false) return { ok: false, error: (r && r.error) || 'the report could not be read', report: null, markdown: '', body: '', written: false, proposals: [] };
123
+ const proposals = (Array.isArray(r.proposals) ? r.proposals : []).map(readProposal);
124
+ const live = proposals.filter((p) => p.kind === 'research' && p.state === 'done' && p.out && p.out.scan);
125
+ if (live.length) {
126
+ const d = await getPortfolio(ws);
127
+ if (d.ok && d.open) for (const p of live) if (d.open.scan === p.out.scan) { p.state = 'running'; p.steps = d.open.steps; }
128
+ }
129
+ return { ok: true, error: '', report: r.report || null, markdown: r.markdown || '', body: r.body || '', written: r.written === true, proposals };
130
+ }
131
+ /** The proposals the PM dismissed, newest first, read from the reports that hold a decided proposal (the portfolio answer lists only those still waiting). */
132
+ export async function getDismissed(ws, reports) {
133
+ const held = reports.filter((x) => x.total > x.open).slice(0, 12);
134
+ const all = await Promise.all(held.map((x) => getReport(ws, x.id)));
135
+ return all.flatMap((r) => (r.ok ? r.proposals : [])).filter((p) => p.state === 'dismissed').sort((a, b) => String(b.decidedAt).localeCompare(String(a.decidedAt)));
136
+ }
137
+
138
+ /* ── presses: each answers the server's JSON, or { ok:false, error } ── */
139
+ const post = (route, ws, body) => pmPost(route, { ws, ...(body || {}) }).catch(fail);
140
+ export const postWatch = (ws, body) => post(ROUTES.watch, ws, body);
141
+ export const postSetting = (ws, body) => post(ROUTES.setting, ws, body);
142
+ export const postNow = (ws, sel) => post(ROUTES.now, ws, { items: sel.items, repo: !!sel.repo, types: sel.types });
143
+ export const postStop = (ws) => post(ROUTES.stop, ws, {});
144
+ export const postProposal = (ws, body) => post(ROUTES.proposal, ws, body);
145
+
146
+ /** The boards of the workspace as [{ id, name }] (the page already reads this list for the Portfolio). */
147
+ export async function getBoards(ws) {
148
+ const b = await pmGet('/api/boards?' + q({ ws })).catch(fail);
149
+ return b && b.ok && Array.isArray(b.boards) ? b.boards.filter((x) => !ws || !x.ws || x.ws === ws).map((x) => ({ id: x.id, name: x.name || x.id })) : [];
150
+ }
151
+
152
+ /* ── choices for the Change first form: the boards, the anchors and the initiatives the page already reads elsewhere ── */
153
+ export async function loadChoices(ws) {
154
+ const boards = await getBoards(ws);
155
+ const first = boards[0] ? boards[0].id : '';
156
+ const a = first ? await pmGet('/api/boards/anchors?' + q({ ws, board: first })).catch(fail) : null;
157
+ const anchors = a && a.ok && Array.isArray(a.anchors) ? a.anchors.map((x) => ({ value: x.value, label: x.label || x.value })) : [];
158
+ const per = await Promise.all(boards.map((x) => pmGet('/api/boards/items?' + q({ ws, board: x.id })).catch(fail)));
159
+ const initiatives = [];
160
+ per.forEach((r) => { if (r && r.ok && Array.isArray(r.initiatives)) for (const i of r.initiatives) if (i.id && !initiatives.some((y) => y.id === i.id)) initiatives.push({ id: i.id, title: i.title || '' }); });
161
+ return { boards, anchors, initiatives };
162
+ }
163
+
164
+ /* ── a change anywhere tells every part of the page that draws the scan ── */
165
+ const subs = new Set();
166
+ export const onScanChange = (fn) => { subs.add(fn); return () => subs.delete(fn); };
167
+ export const scanChanged = () => { for (const fn of [...subs]) { try { fn(); } catch { /* one part failing never stops the rest */ } } };
@@ -0,0 +1,248 @@
1
+ /* pm/c3-scan-next.js — the "Proposed next steps (N)" panel of a Horizon scan document, and the way a scan document opens (initiative
2
+ * horizon-scan-portfolio, PRD section 4; mockup 42-hscan.js X.panel).
3
+ *
4
+ * A scan report is a markdown file in the state folder (`<state>/reports/scan-<type>/<date>.md`) whose front matter says `source: scan:portfolio`
5
+ * and `report: <record id>`. It opens in the EXISTING document sheet (c3-doc-sheet.js) by its path: GET /api/doc reads any file inside the
6
+ * workspace root, and the state folder is inside it. This module adds the one thing the sheet does not know: the panel of proposals. It joins the
7
+ * sheet through the `docAbove` list that c3-doc-sheet.js exports, so the sheet holds no scan code. The panel shows only for a document whose front
8
+ * matter has `source: scan:*`. It sits under the sheet's header and tabs, collapsed, and scrolls with the text.
9
+ *
10
+ * Each proposal has a kind: create (title, board, lane, anchor, intent, starts in), research (question, depth, result) or attach
11
+ * (initiative). Each has FROM (the finding in the document), EVIDENCE chips, Approve, Change first (the fields as a form, then
12
+ * Approve with changes) and Dismiss (a one-line reason is required). Nothing acts without a press. Calls: c3-scan-api.js only.
13
+ * openScanDoc(ws, report, { panel }) open a report in the document sheet; panel: true opens it with the proposals showing
14
+ * proposalsPanel({ ws, path, text }) the panel node (the sheet's hook calls it)
15
+ * goBoard(board, tab) open a board on the Initiatives tab
16
+ * Undo takes back a dismissal, and a change made with Change first. Approve has no Undo: create made a real initiative and research started runs.
17
+ * Pure parts (Node-testable): outOf, evidenceHref, shortRef, sumLine.
18
+ */
19
+ import { el } from './dom.js';
20
+ import { icon, pbtn, lk, btn, chip, kv, put } from './c3-ini-ui.js';
21
+ import { toast } from './c3-toast.js';
22
+ import { docAbove } from './c3-doc-sheet.js';
23
+ import { frontOf, getReport, getPortfolio, getBoards, loadChoices, postProposal, postStop, readOut, scanChanged, onScanChange } from './c3-scan-api.js';
24
+
25
+ export const KIND_WORDS = { create: ['Create initiative', 'init'], research: ['Research more', 'search'], attach: ['Add to an existing initiative', 'plus'] };
26
+ export const LANES = [['feature', 'Feature'], ['discovery', 'Discovery'], ['chore', 'Chore'], ['content', 'Content']];
27
+ export const DEPTHS = [['quick', 'Quick', 'About 8 public pages, 5 minutes'], ['standard', 'Standard', 'About 20 public pages, 10 minutes'], ['deep', 'Deep', 'About 50 public pages, 30 minutes']];
28
+ const EV_IC = { web: 'out', jira: 'org', note: 'doc', slack: 'mail', repo: 'repo' };
29
+ const EV_T = { web: 'Public page', jira: 'Jira item', note: 'Note', slack: 'Slack thread', repo: 'Repository' };
30
+ const isOpen = (p) => p.state === 'open' || p.state === 'running';
31
+ const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : '');
32
+
33
+ /** What an approve press made, from the server's answer: { id, board, stage } (the initiative is an object in the answer: result.initiative.id). */
34
+ export function outOf(r) {
35
+ const o = readOut(r && r.result);
36
+ return o ? { id: o.id, board: o.board, stage: o.stage } : { id: '', board: '', stage: '' };
37
+ }
38
+ /** The address a public-page evidence chip opens, or '' for a chip that opens nothing (a Jira key, a note, a path, or a scheme other than http). */
39
+ export function evidenceHref(e) {
40
+ const ref = String(e && e.ref || '').trim();
41
+ if (!ref || (e && e.kind && e.kind !== 'web')) return '';
42
+ if (/^https?:\/\//i.test(ref)) return ref;
43
+ if (/^[a-z][a-z0-9+.-]*:/i.test(ref)) return '';
44
+ return /^[\w-]+(\.[\w-]+)+(\/\S*)?$/.test(ref) ? 'https://' + ref : '';
45
+ }
46
+ export const shortRef = (ref) => String(ref || '').replace(/^https?:\/\/(www\.)?/i, '').replace(/\/$/, '');
47
+ /** "1 create initiative, 1 research more, 1 add to an existing initiative", or "All handled." */
48
+ export function sumLine(list) {
49
+ const open = list.filter(isOpen);
50
+ if (!open.length) return 'All handled.';
51
+ return ['create', 'research', 'attach'].map((k) => [k, open.filter((p) => p.kind === k).length]).filter((x) => x[1]).map(([k, c]) => c + ' ' + KIND_WORDS[k][0].toLowerCase()).join(', ');
52
+ }
53
+
54
+ /* ── open a scan document ── */
55
+ const WANT_PANEL = new Set();
56
+ export async function openScanDoc(ws, r, o = {}) {
57
+ if (!r || !r.path || r.written === false) { toast({ text: 'This report is not written yet. It opens when the scan ends.', tone: 'error' }); return; }
58
+ if (o.panel) WANT_PANEL.add(r.path);
59
+ const { openDocSheet } = await import('./c3-doc-sheet.js');
60
+ return openDocSheet({ ws, doc: { path: r.path, name: r.title || r.path, ty: 'Report', v: '', st: 'current', ini: '', iniName: 'Portfolio' }, mode: 'read' });
61
+ }
62
+
63
+ /** Open a board on the Initiatives tab (the way the Briefing's old card did: the address, then the shell's own go). */
64
+ export function goBoard(board, tab = 'initiatives') {
65
+ try { const u = new URL(location.href); u.searchParams.set('init', 'board:' + encodeURIComponent(board) + ':' + tab); history.replaceState(history.state, '', u); } catch { /* no history */ }
66
+ return import('./c3-brief.js').then((m) => { if (m.hooks && m.hooks.go) m.hooks.go({ action: 'goinit' }); try { window.dispatchEvent(new CustomEvent('pmos:ini-go', { detail: { board, tab } })); } catch { /* no events */ } });
67
+ }
68
+
69
+ /* ── the panel ── */
70
+ export function proposalsPanel({ ws, path, text }) {
71
+ const F = frontOf(text);
72
+ const S = { list: [], loaded: false, err: '', open: WANT_PANEL.delete(path), busy: new Set(), ui: {}, boards: [], choices: null, reportId: F.report || '', timer: null, seen: new Map() };
73
+ const root = el('section', { class: 'sc-next', 'aria-label': 'Proposed next steps', 'data-scan-next': '', hidden: true });
74
+ const st = (id) => (S.ui[id] ||= { mode: 'view', draft: {}, why: '', bad: false });
75
+ const name = (b) => (S.boards.find((x) => x.id === b) || { name: b || '' }).name;
76
+
77
+ async function load() {
78
+ let id = S.reportId;
79
+ if (!id) { const d = await getPortfolio(ws); const r = d.reports.find((x) => x.path === path); id = r ? r.id : ''; S.reportId = id; }
80
+ if (!id) { S.loaded = true; draw(); return; }
81
+ const r = await getReport(ws, id);
82
+ S.loaded = true; S.err = r.ok ? '' : r.error;
83
+ if (r.ok) {
84
+ for (const p of r.proposals) { const was = S.seen.get(p.id); if (was === 'running' && p.state === 'done' && p.kind === 'research') toast('Research finished. The follow-up document is in Documents, linked to this one.'); S.seen.set(p.id, p.state); }
85
+ S.list = r.proposals;
86
+ }
87
+ draw();
88
+ clearTimeout(S.timer);
89
+ if (S.list.some((p) => p.state === 'running')) S.timer = setTimeout(() => { if (root.isConnected) load(); }, 2500);
90
+ }
91
+ // The form draws once the boards and anchors are in, so a redraw never replaces a field the PM is typing in.
92
+ async function choices() { if (!S.choices) { try { S.choices = await loadChoices(ws); } catch { S.choices = { boards: [], anchors: [], initiatives: [] }; } } draw(); }
93
+ async function press(p, body, after) {
94
+ if (S.busy.has(p.id)) return null;
95
+ S.busy.add(p.id); draw();
96
+ const r = await postProposal(ws, { id: p.id, ...body });
97
+ S.busy.delete(p.id);
98
+ if (!r || r.ok === false) { toast({ text: 'That did not work: ' + ((r && r.error) || 'the workspace did not answer') + '.', tone: 'error', timeout: 6000 }); draw(); return null; }
99
+ if (after) after(r);
100
+ await load(); scanChanged();
101
+ return r;
102
+ }
103
+ const undo = (p, what = 'dismissal') => press(p, { action: 'undo' }, () => toast(what === 'dismissal' ? 'Undone. The proposal waits for you again.' : 'Your changes are undone. The proposal is as the agent filed it.'));
104
+ const valOf = (p, k) => { const d = st(p.id).draft; return k in d ? d[k] : p.fields[k]; };
105
+ async function approve(p) {
106
+ const u = st(p.id); const target = valOf(p, 'initiative');
107
+ if (u.mode === 'change' && Object.keys(u.draft).length) {
108
+ const c = await press(p, { action: 'change', fields: { ...u.draft } });
109
+ if (!c) return;
110
+ }
111
+ u.mode = 'view'; u.draft = {};
112
+ // Approve has no Undo: create makes a real initiative on the board and research starts runs. The toast says what happened.
113
+ await press(p, { action: 'approve' }, (r) => {
114
+ if (p.kind === 'create') { const o = outOf(r); const stage = o.stage || 'its first stage'; toast({ text: (o.id ? o.id + ' created' : 'Initiative created') + (o.board ? ' on ' + name(o.board) : '') + ' in ' + stage + ', with the report as evidence. The Initiative Driver starts on ' + stage + '.', timeout: 8000 }); }
115
+ else if (p.kind === 'attach') toast({ text: 'Attached to ' + (target || 'the initiative') + ' as evidence. Nothing else changed.', timeout: 8000 });
116
+ else toast('Research started. It reads public pages by topic only and sends nothing.');
117
+ });
118
+ }
119
+ async function dismiss(p) {
120
+ const u = st(p.id); const why = u.why.trim();
121
+ if (!why) { u.bad = true; draw(); const f = root.querySelector('[data-scan-f="why"][data-id="' + p.id + '"]'); if (f) f.focus(); return; }
122
+ u.mode = 'view'; u.bad = false;
123
+ await press(p, { action: 'dismiss', why }, () => toast({ text: 'Dismissed. Counted as a correction. The scan will not raise this again.', onAction: () => undo(p), timeout: 8000 }));
124
+ }
125
+ function jump(p) {
126
+ const sheet = root.closest('.sheet') || document;
127
+ const rb = sheet.querySelector('[data-dmode="read"]');
128
+ if (rb && rb.getAttribute('aria-pressed') !== 'true') rb.click();
129
+ setTimeout(() => {
130
+ const want = String(p.from || '').toLowerCase(); const head = want.split(':')[0].trim();
131
+ const nodes = [...sheet.querySelectorAll('h1,h2,h3,h4,h5,li,p,td,th')].filter((n) => !root.contains(n));
132
+ const hit = nodes.find((n) => want && n.textContent.toLowerCase().includes(want)) || nodes.find((n) => head && n.textContent.toLowerCase().includes(head));
133
+ if (hit) { hit.scrollIntoView({ block: 'center' }); hit.classList.remove('flash'); void hit.offsetWidth; hit.classList.add('flash'); }
134
+ }, 60);
135
+ }
136
+ async function openFollowUp(p) {
137
+ const d = await getPortfolio(ws);
138
+ const r = d.reports.find((x) => p.out && p.out.report && x.id === p.out.report) || d.reports.find((x) => x.parent === p.report) || d.reports.find((x) => x.parent && x.parent === S.reportId);
139
+ if (r) openScanDoc(ws, r, { panel: true }); else toast({ text: 'The follow-up document is not in the list yet.', tone: 'error' });
140
+ }
141
+
142
+ /* ── drawing ── */
143
+ const field = (label, ctl) => el('label', { class: 'sc-f' }, el('span', { class: 'dim mono' }, label), ctl);
144
+ const opts = (list, cur) => { const l = list.map((o) => (Array.isArray(o) ? o : [o, o])); if (cur && !l.some((o) => String(o[0]) === String(cur))) l.unshift([cur, cur]); return l.map(([v, t]) => el('option', { value: v, selected: String(v) === String(cur) }, t)); };
145
+ const input = (p, k, label, attrs) => el('input', { type: 'text', value: valOf(p, k) || '', 'aria-label': label, 'data-scan-f': k, 'data-id': p.id, oninput: (e) => { st(p.id).draft[k] = e.target.value; }, ...(attrs || {}) });
146
+ const area = (p, k, label) => { const a = el('textarea', { rows: '2', 'aria-label': label, 'data-scan-f': k, 'data-id': p.id, oninput: (e) => { st(p.id).draft[k] = e.target.value; } }); a.value = valOf(p, k) || ''; return a; };
147
+ const select = (p, k, label, list) => el('select', { 'aria-label': label, 'data-scan-f': k, 'data-id': p.id, onchange: (e) => { st(p.id).draft[k] = e.target.value; } }, ...opts(list, valOf(p, k)));
148
+
149
+ function details(p) {
150
+ const f = p.fields; const ch = st(p.id).mode === 'change'; const C = S.choices || { boards: [], anchors: [], initiatives: [] };
151
+ if (ch && !S.choices) return el('div', { class: 'sc-form', 'data-scan-form': p.id, 'aria-busy': 'true' }, el('span', { class: 'dim' }, 'Reading the boards and anchors…'));
152
+ if (p.kind === 'create') {
153
+ if (!ch) return kv([['Title', f.title], ['Board', [name(f.board) || 'the agent will choose', ' ', el('span', { class: 'tag' }, p.changed ? 'changed by you' : 'chosen by the agent')]], ['Lane', cap(f.lane)], ['Anchor', f.anchor],
154
+ ['Intent', f.intent], ['Starts in', "The first stage of the board's workflow, with this document as evidence"]], { 'data-scan-kv': 'create' });
155
+ return el('div', { class: 'sc-form', 'data-scan-form': p.id },
156
+ field('TITLE', input(p, 'title', 'Initiative title')),
157
+ el('div', { class: 'sc-row' }, field('BOARD', select(p, 'board', 'Board', (C.boards.length ? C.boards : [{ id: f.board, name: name(f.board) }]).map((b) => [b.id, b.name + (b.id === f.board && !p.changed ? ' (agent choice)' : '')]))),
158
+ field('LANE', select(p, 'lane', 'Lane', LANES))),
159
+ field('ANCHOR (A KEY RESULT OR A HEALTH BUDGET)', C.anchors.length ? select(p, 'anchor', 'Anchor', C.anchors.map((a) => [a.value, a.label])) : input(p, 'anchor', 'Anchor', { placeholder: 'A key result or health:<id>' })),
160
+ field('INTENT', area(p, 'intent', 'Intent')));
161
+ }
162
+ if (p.kind === 'research') {
163
+ const dp = DEPTHS.find((x) => x[0] === valOf(p, 'depth')) || DEPTHS[1];
164
+ if (!ch) return kv([['Question', f.question], ['Sources', 'Public pages on the topic. Nothing from the repository is sent.'], ['Depth', dp[1] + ': ' + dp[2]], ['Result', 'A new document in Documents, linked to this one']], { 'data-scan-kv': 'research' });
165
+ return el('div', { class: 'sc-form', 'data-scan-form': p.id }, field('QUESTION', area(p, 'question', 'Research question')),
166
+ field('DEPTH', select(p, 'depth', 'Depth', DEPTHS.map((x) => [x[0], x[1] + ': ' + x[2]]))), el('div', { class: 'dim sc-sm' }, 'Public pages on the topic. Nothing from the repository is sent.'));
167
+ }
168
+ if (!ch) return kv([['Attach to', f.initiative ? chip(icon('init', 'sm'), (C.initiatives.find((i) => i.id === f.initiative) ? f.initiative + ' ' + C.initiatives.find((i) => i.id === f.initiative).title : f.initiative)) : '—'], ['What it adds', p.why], ['Shown in', 'Evidence and research on that initiative']], { 'data-scan-kv': 'attach' });
169
+ return el('div', { class: 'sc-form', 'data-scan-form': p.id }, field('ATTACH TO', C.initiatives.length ? select(p, 'initiative', 'Initiative', C.initiatives.map((i) => [i.id, i.id + ' ' + i.title])) : input(p, 'initiative', 'Initiative', { placeholder: 'An initiative id, e.g. IDPF-12' })),
170
+ el('div', { class: 'dim sc-sm' }, p.why));
171
+ }
172
+ function acts(p) {
173
+ const u = st(p.id); const busy = S.busy.has(p.id); const ch = u.mode === 'change';
174
+ const verb = p.kind === 'research' ? 'Approve and start' : p.kind === 'attach' ? 'Approve and attach' : 'Approve';
175
+ if (u.mode === 'dismiss') {
176
+ const why = el('input', { type: 'text', value: u.why, placeholder: 'For example: we already decided this', 'aria-label': 'Reason for dismissing', 'data-scan-f': 'why', 'data-id': p.id, class: u.bad ? 'bad' : '',
177
+ oninput: (e) => { u.why = e.target.value; u.bad = false; }, onkeydown: (e) => { if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); dismiss(p); } } });
178
+ return el('div', { class: 'sc-form' }, field('WHY DO YOU DISMISS IT? ONE LINE', why),
179
+ u.bad ? el('div', { class: 'fold w', role: 'alert', 'data-scan-need-why': '' }, icon('alert', 'sm'), 'Say why. The reason is kept and the scan will not raise this again.') : null,
180
+ el('div', { class: 'dim sc-sm' }, 'A dismissal counts as a correction. The scan will not raise the same thing again.'),
181
+ el('div', { class: 'chips2' }, pbtn('primary', 'Dismiss', 'x', { 'data-scan-act': 'dismiss-ok', 'data-id': p.id, disabled: busy, onclick: () => dismiss(p) }), pbtn('ghost', 'Cancel', null, { 'data-scan-act': 'cancel', 'data-id': p.id, onclick: () => { u.mode = 'view'; u.bad = false; draw(); } })));
182
+ }
183
+ return el('div', { class: 'chips2' }, pbtn('primary', ch ? 'Approve with changes' : verb, 'check', { 'data-scan-act': 'approve', 'data-id': p.id, disabled: busy, onclick: () => approve(p) }),
184
+ p.changed ? pbtn('ghost', 'Undo my changes', null, { 'data-scan-act': 'undo-change', 'data-id': p.id, disabled: busy, onclick: () => { u.mode = 'view'; u.draft = {}; undo(p, 'change'); } }) : null,
185
+ ch ? pbtn('outline', 'Cancel', null, { 'data-scan-act': 'cancel', 'data-id': p.id, onclick: () => { u.mode = 'view'; u.draft = {}; draw(); } })
186
+ : pbtn('outline', 'Change first', 'edit', { 'data-scan-act': 'change', 'data-id': p.id, disabled: busy, onclick: () => { u.mode = 'change'; draw(); choices(); } }),
187
+ pbtn('ghost', 'Dismiss', 'x', { 'data-scan-act': 'dismiss', 'data-id': p.id, disabled: busy, onclick: () => { u.mode = 'dismiss'; draw(); const f = root.querySelector('[data-scan-f="why"][data-id="' + p.id + '"]'); if (f) f.focus(); } }));
188
+ }
189
+ function result(p) {
190
+ if (p.state === 'dismissed') return el('div', { class: 'fold', 'data-scan-result': 'dismissed' }, icon('x', 'sm'), ' Dismissed' + (p.reason ? ': ' + p.reason : '') + '. Counted as a correction. The scan will not raise this again. ', pbtn('ghost', 'Undo', null, { 'data-scan-act': 'undo', 'data-id': p.id, onclick: () => undo(p) }));
191
+ if (p.kind === 'research') return el('div', { class: 'fold a', 'data-scan-result': 'research' }, icon('check', 'sm'), ' Research finished. ', pbtn('primary', 'Open the follow-up document', 'out', { 'data-scan-act': 'follow-up', 'data-id': p.id, onclick: () => openFollowUp(p) }));
192
+ if (p.kind === 'attach') return el('div', { class: 'fold a', 'data-scan-result': 'attach' }, icon('check', 'sm'), ' Attached to ' + ((p.out && p.out.id) || valOf(p, 'initiative')) + ' as evidence.');
193
+ const o = p.out || {};
194
+ return el('div', { class: 'fold a', 'data-scan-result': 'create' }, icon('check', 'sm'), ' Created ' + (o.id || 'the initiative') + (o.board ? ' on ' + name(o.board) : '') + (o.stage ? ', in ' + o.stage + '. The Initiative Driver starts on ' + o.stage : '') + '. ',
195
+ o.board ? pbtn('outline', 'Open in the board', 'out', { 'data-scan-act': 'go-board', 'data-id': p.id, onclick: () => goBoard(o.board) }) : null);
196
+ }
197
+ function runBox(p) {
198
+ const steps = Array.isArray(p.steps) && p.steps.length ? p.steps : [{ label: 'Research is running', state: 'running' }];
199
+ return el('div', { class: 'sc-run', role: 'status', 'aria-label': 'Research running', 'data-scan-run': p.id },
200
+ el('ul', { class: 'chgl' }, ...steps.map((s) => el('li', null, icon(s.state === 'done' ? 'check' : s.state === 'failed' ? 'alert' : s.state === 'pending' || s.state === 'skipped' ? 'info' : 'clock', 'sm'), el('span', { class: s.state === 'pending' || s.state === 'skipped' ? 'dim' : '' }, s.label), el('span', { class: 'dim' }, s.state === 'done' ? 'done' : s.state === 'running' ? 'now' : s.state === 'failed' ? 'failed' : s.state === 'skipped' ? 'skipped' : '')))),
201
+ el('div', { class: 'chips2' }, pbtn('outline', 'Stop', 'x', { 'data-scan-act': 'rstop', 'data-id': p.id, onclick: async () => { const r = await postStop(ws); if (r && r.ok === false) toast({ text: 'Stop did not work: ' + (r.error || 'no answer') + '.', tone: 'error' }); else toast('Research stopped. Nothing was written.'); await load(); scanChanged(); } }),
202
+ el('span', { class: 'dim sc-sm' }, 'Reads public pages by topic only. Sends nothing.')));
203
+ }
204
+ function item(p) {
205
+ const fin = p.state === 'done' || p.state === 'dismissed';
206
+ const k = KIND_WORDS[p.kind];
207
+ return el('li', { class: 'sc-p', 'data-scan-prop': p.id, 'data-scan-kind': p.kind, 'data-scan-state': p.state },
208
+ el('div', { class: 'l1' }, el('span', { class: 'sc-ki' }, icon(k[1], 'sm')), el('b', null, p.title), el('span', { class: 'tag' + (p.kind === 'create' ? ' acc' : '') }, k[0])),
209
+ p.why ? el('p', null, p.why) : null,
210
+ p.from ? el('div', { class: 'chips' }, el('span', { class: 'dim mono sc-sm' }, 'FROM'), lk({ 'data-scan-act': 'jump', 'data-id': p.id, onclick: () => jump(p) }, icon('eye', 'sm'), p.from)) : null,
211
+ p.evidence.length ? el('div', { class: 'chips' }, el('span', { class: 'dim mono sc-sm' }, 'EVIDENCE'), ...p.evidence.map(evChip)) : null,
212
+ fin ? result(p) : p.state === 'running' ? runBox(p) : [details(p), acts(p)]);
213
+ }
214
+ function draw() {
215
+ const l = S.list;
216
+ root.hidden = !l.length;
217
+ if (!l.length) { put(root); return; }
218
+ const n = l.filter(isOpen).length;
219
+ put(root,
220
+ el('div', { class: 'hd' },
221
+ btn({ class: 'sc-tg', 'data-scan-act': 'panel', 'aria-expanded': String(S.open), onclick: () => { S.open = !S.open; draw(); } }, icon('next', 'sm'), el('b', null, 'Proposed next steps (' + n + ')'), el('span', { class: 'dim' }, sumLine(l))),
222
+ el('span', { class: 'tag acc' }, icon('spark', 'sm'), 'Nothing happens until you click')),
223
+ S.open ? el('ul', { class: 'sc-pl' }, ...l.map(item)) : null,
224
+ S.open ? el('div', { class: 'dim sc-sm' }, 'Approve, change or dismiss each one. A dismissal needs a reason and counts as a correction. The scan does not raise it again.') : null);
225
+ }
226
+
227
+ getBoards(ws).then((b) => { S.boards = b; draw(); });
228
+ load();
229
+ const off = onScanChange(() => { if (!root.isConnected) { off(); clearTimeout(S.timer); return; } load(); });
230
+ return root;
231
+ }
232
+
233
+ function evChip(e) {
234
+ const k = EV_IC[e.kind] ? e.kind : 'note';
235
+ const inner = [icon(EV_IC[k], 'sm'), el('span', { class: 'mono' }, shortRef(e.ref))];
236
+ const tip = (e.label ? e.label + '. ' : '') + EV_T[k];
237
+ const href = evidenceHref(e);
238
+ if (href) return el('a', { class: 'chip2 lk sc-link', href, target: '_blank', rel: 'noopener noreferrer', title: tip, 'data-scan-ev': k }, ...inner);
239
+ return el('span', { class: 'chip2', title: tip, 'data-scan-ev': k }, ...inner);
240
+ }
241
+
242
+ /* ── the sheet's hook: a panel for a document that says `source: scan:*`, one node per open sheet ── */
243
+ const NODES = new WeakMap();
244
+ docAbove.push(({ ws, doc, text, key }) => {
245
+ if (text == null || !/^scan:/.test(frontOf(text).source || '')) return null;
246
+ if (!NODES.has(key)) NODES.set(key, proposalsPanel({ ws, path: doc.path, text }));
247
+ return NODES.get(key);
248
+ });