create-pmos 0.131.0 → 0.133.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 (65) 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/bin/pmos.js +18 -2
  7. package/workspace/guide/data-freshness.md +40 -0
  8. package/workspace/guide/index.md +2 -0
  9. package/workspace/guide/welcome.md +38 -0
  10. package/workspace/lib/calendar-cli.js +84 -0
  11. package/workspace/lib/facts-async.js +144 -35
  12. package/workspace/lib/freshness.js +167 -0
  13. package/workspace/lib/import-overlay.js +32 -0
  14. package/workspace/lib/offloop.js +94 -0
  15. package/workspace/lib/pmos-verbs.js +3 -0
  16. package/workspace/lib/route-modules.js +2 -0
  17. package/workspace/lib/routes-board.js +3 -1
  18. package/workspace/lib/routes-my-work.js +27 -9
  19. package/workspace/lib/routes-onboarding.js +215 -0
  20. package/workspace/lib/routes-sources.js +64 -0
  21. package/workspace/lib/vendor/scripts/anchor-resolve.js +351 -0
  22. package/workspace/lib/vendor/scripts/delivery-index.js +356 -0
  23. package/workspace/lib/vendor/scripts/intake.js +78 -0
  24. package/workspace/lib/vendor/scripts/lane-defs.js +226 -0
  25. package/workspace/lib/vendor/scripts/migrate-layout.js +775 -0
  26. package/workspace/lib/vendor/scripts/nine-stages.js +81 -0
  27. package/workspace/lib/vendor/scripts/onboarding/apply.js +110 -0
  28. package/workspace/lib/vendor/scripts/onboarding/brief.js +154 -0
  29. package/workspace/lib/vendor/scripts/onboarding/first-briefing.js +41 -0
  30. package/workspace/lib/vendor/scripts/onboarding/first-look.js +84 -0
  31. package/workspace/lib/vendor/scripts/onboarding/infer-brief.js +70 -0
  32. package/workspace/lib/vendor/scripts/onboarding/proposals.js +98 -0
  33. package/workspace/lib/vendor/scripts/onboarding/scan-worker.js +27 -0
  34. package/workspace/lib/vendor/scripts/onboarding/scan.js +859 -0
  35. package/workspace/lib/vendor/scripts/onboarding/structure.js +217 -0
  36. package/workspace/lib/vendor/scripts/plan-items-stage.js +271 -0
  37. package/workspace/lib/vendor/scripts/read-jsonl.js +53 -0
  38. package/workspace/lib/vendor/scripts/signals/layout-paths.js +43 -0
  39. package/workspace/lib/vendor/scripts/status-facts.js +214 -0
  40. package/workspace/lib/vendor/scripts/trust/import-guard.js +36 -0
  41. package/workspace/lib/vendor/scripts/trust/ledger.js +53 -0
  42. package/workspace/lib/vendor/scripts/workflow-state.js +1888 -0
  43. package/workspace/lib/vendor/workspace/lib/actor.js +91 -0
  44. package/workspace/lib/vendor/workspace/lib/board-cache.js +156 -0
  45. package/workspace/lib/vendor/workspace/lib/console-state.js +295 -0
  46. package/workspace/lib/vendor/workspace/lib/doc-category.js +227 -0
  47. package/workspace/lib/vendor/workspace/lib/factory-join.js +158 -0
  48. package/workspace/lib/vendor/workspace/lib/git-info.js +306 -0
  49. package/workspace/lib/vendor/workspace/lib/plan-items.js +922 -0
  50. package/workspace/lib/vendor/workspace/lib/plan-tree.js +419 -0
  51. package/workspace/lib/vendor/workspace/lib/review-gate.js +39 -0
  52. package/workspace/lib/vendor/workspace/lib/sink.js +321 -0
  53. package/workspace/lib/vendor/workspace/lib/state-root.js +587 -0
  54. package/workspace/package-lock.json +2 -2
  55. package/workspace/package.json +1 -1
  56. package/workspace/public/pm/c3-shell.js +4 -1
  57. package/workspace/public/pm/components.css +313 -0
  58. package/workspace/public/pm/dest-settings.js +10 -1
  59. package/workspace/public/pm/freshness-line.js +123 -0
  60. package/workspace/public/pm/welcome.js +283 -0
  61. package/workspace/public/welcome.html +20 -0
  62. package/workspace/server.js +41 -4
  63. package/workspace/skills/calendar-sync.skill +30 -0
  64. package/workspace/test-lists/onboarding-firstrun.json +15 -0
  65. package/workspace/test-lists/workspace-data-freshness.json +6 -0
@@ -0,0 +1,283 @@
1
+ /* pm/welcome.js — the first-run page (/welcome). One primary button per screen.
2
+ *
3
+ * Welcome → First look → Your portfolio → Questions (only when the scan left any) → First Briefing → More sources
4
+ *
5
+ * Routes (lib/routes-onboarding.js): GET /api/onboarding/status|structure|briefing, POST /api/onboarding/scan|apply|undo.
6
+ * More sources uses POST /api/sources/refresh (lib/routes-sources.js): it starts the source's read-only skill as a session in
7
+ * the workspace with the PM's own connection. Nothing is read until the PM presses Bring in; the server calls nothing outside.
8
+ * Presses carry the launch nonce through pm/http.js. The page writes nothing itself; text taken from the scanned folder is
9
+ * untrusted, so every value goes through esc() and no quoted text from a file is shown.
10
+ * data-testid attributes are the browser suite's handles.
11
+ */
12
+ import { pmGet, pmPost } from './http.js';
13
+
14
+ const root = document.getElementById('wl-root');
15
+ const params = new URLSearchParams(location.search);
16
+ const S = {
17
+ ws: params.get('ws') || '', folder: null, added: [], step: 'welcome', busy: '', error: '', note: '',
18
+ look: null, structure: null, checked: {}, titles: {}, boardOf: {}, open: {}, answers: {}, skipped: {}, importId: '', briefing: [], sources: {},
19
+ };
20
+ const STEPS = [['welcome', 'Welcome'], ['look', 'First look'], ['portfolio', 'Your portfolio'], ['questions', 'Questions'], ['briefing', 'First Briefing'], ['sources', 'More sources']];
21
+ /* The sources a first run can bring in. `source` is a lib/freshness.js SOURCES id with a read-only skill. Inbox and Slack
22
+ * have no import skill yet, so the page says so instead of offering a button that cannot work. */
23
+ const MORE_SOURCES = [
24
+ { source: 'calendar', name: 'Calendar', adds: 'Your meetings today and for the next 14 days, so the Briefing can get you ready for them.' },
25
+ { source: 'my-work', name: 'Jira', adds: 'The Jira issues assigned to you, so My work starts with them.' },
26
+ ];
27
+
28
+ const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
29
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
30
+ const ref = (e) => (e ? (e.line ? `${e.file}:${e.line}` : e.file) : '');
31
+
32
+ /* ── model ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
33
+ const rowsOf = () => (S.structure ? S.structure.boards.flatMap((b) => b.initiatives.map((r) => ({ ...r, board: b.key }))) : []);
34
+ const boardOfRow = (r) => S.boardOf[r.proposal_id] || r.board;
35
+ function counts() {
36
+ const on = rowsOf().filter((r) => S.checked[r.proposal_id]);
37
+ return { n: on.length, m: new Set(on.map(boardOfRow)).size };
38
+ }
39
+ function adopt(structure) {
40
+ S.structure = structure; S.checked = {}; S.titles = {}; S.boardOf = {}; S.open = {}; S.answers = {}; S.skipped = {};
41
+ for (const r of rowsOf()) { S.checked[r.proposal_id] = !!r.checked; S.titles[r.proposal_id] = r.title; }
42
+ }
43
+ function revisions() {
44
+ const out = {};
45
+ for (const r of rowsOf()) {
46
+ const rev = {};
47
+ if (S.titles[r.proposal_id] !== r.title && String(S.titles[r.proposal_id]).trim()) rev.title = String(S.titles[r.proposal_id]).trim();
48
+ if (S.boardOf[r.proposal_id] && S.boardOf[r.proposal_id] !== r.board) rev.board = S.boardOf[r.proposal_id];
49
+ if (Object.keys(rev).length) out[r.proposal_id] = rev;
50
+ }
51
+ return out;
52
+ }
53
+
54
+ /* ── actions ─────────────────────────────────────────────────────────────────────────────────────────────────────── */
55
+ const fail = (r, what) => { S.error = (r && r.error) || `${what} did not work.`; };
56
+ async function run(label, fn) {
57
+ S.busy = label; S.error = ''; render();
58
+ try { await fn(); } catch (e) { S.error = String((e && e.message) || e); }
59
+ S.busy = ''; render();
60
+ }
61
+ async function loadStructure() {
62
+ const r = await pmGet(`/api/onboarding/structure?ws=${encodeURIComponent(S.ws)}`);
63
+ if (!r || !r.ok) { fail(r, 'Reading the portfolio'); return false; }
64
+ adopt(r); return true;
65
+ }
66
+ async function loadBriefing() {
67
+ const r = await pmGet(`/api/onboarding/briefing?ws=${encodeURIComponent(S.ws)}`);
68
+ if (!r || !r.ok) { fail(r, 'Reading the Briefing'); return false; }
69
+ S.briefing = r.items || []; return true;
70
+ }
71
+ const addFolder = () => run('add', async () => {
72
+ const p = await pmPost('/api/pick-folder', {});
73
+ if (!p || !p.ok) { if (p && p.cancelled) return; S.note = (p && p.unsupported) ? 'This system has no folder chooser. Start pmos in the folder instead.' : ''; if (!S.note) fail(p, 'Choosing a folder'); return; }
74
+ const w = await pmPost('/api/workspaces', { root: p.root, name: p.root.split(/[\\/]/).filter(Boolean).pop(), focus: false });
75
+ if (!w || !w.workspace) { fail(w, 'Adding the folder'); return; }
76
+ if (!S.added.some((a) => a.id === w.workspace.id) && w.workspace.id !== S.ws) S.added.push({ id: w.workspace.id, name: w.workspace.name, root: w.workspace.root });
77
+ });
78
+ const scan = () => run('scan', async () => {
79
+ const r = await pmPost('/api/onboarding/scan', { ws: S.ws, extra_roots: S.added.map((a) => a.root) });
80
+ if (!r || !r.ok) { fail(r, 'The scan'); return; }
81
+ S.look = r.first_look; S.step = 'look';
82
+ });
83
+ const toPortfolio = () => run('structure', async () => { if (await loadStructure()) S.step = 'portfolio'; });
84
+ async function applyNow() {
85
+ const include = rowsOf().filter((r) => S.checked[r.proposal_id]).map((r) => r.proposal_id);
86
+ const answers = {};
87
+ // an untouched question sends its default (the PM saw it and pressed Import); a skipped one sends nothing
88
+ for (const q of (S.structure.questions || [])) {
89
+ if (S.skipped[q.id]) continue;
90
+ const v = S.answers[q.id] != null ? S.answers[q.id] : q.default;
91
+ if (q.id === 'template') answers.template = Object.assign(answers.template || {}, { [q.board]: v });
92
+ else answers[q.id] = v;
93
+ }
94
+ const r = await pmPost('/api/onboarding/apply', { ws: S.ws, include, revisions: revisions(), answers });
95
+ if (!r || !r.ok) { fail(r, 'The import'); return; }
96
+ S.importId = r.import_id;
97
+ if (await loadBriefing()) S.step = 'briefing';
98
+ }
99
+ const importNow = () => {
100
+ if (!counts().n) return;
101
+ if ((S.structure.questions || []).length && S.step !== 'questions') { S.step = 'questions'; render(); return; }
102
+ run('apply', applyNow);
103
+ };
104
+ // "Not now": Welcome stops opening on its own for this folder (`pmos welcome` still opens it), then the console opens
105
+ const notNow = () => run('dismiss', async () => {
106
+ const r = await pmPost('/api/onboarding/dismiss', { ws: S.ws });
107
+ if (!r || !r.ok) { fail(r, 'Not now'); return; }
108
+ location.href = '/c3.html?ws=' + encodeURIComponent(S.ws);
109
+ });
110
+ const undo = () => run('undo', async () => {
111
+ const r = await pmPost('/api/onboarding/undo', { ws: S.ws, import_id: S.importId });
112
+ if (!r || !r.ok) { fail(r, 'Undo'); return; }
113
+ S.importId = '';
114
+ if (await loadStructure()) S.step = 'portfolio';
115
+ });
116
+
117
+ /* ── views ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
118
+ const spin = (txt) => `<span class="wl-spin" data-testid="loading" aria-hidden="true"></span>${esc(txt)}`;
119
+
120
+ function viewWelcome() {
121
+ const f = S.folder || { name: '', root: '' };
122
+ const added = S.added.length ? `<ul class="wl-added" data-testid="added-folders">${S.added.map((a) => `<li data-testid="added-folder">${esc(a.name)} <span class="wl-path">${esc(a.root)}</span></li>`).join('')}</ul>` : '';
123
+ return `<h1 class="wl-h1">Welcome to PMOS</h1>
124
+ <p class="wl-p wl-muted">PMOS reads your folder and shows you what it found: your initiatives, your documents and what needs you first.</p>
125
+ <section class="wl-card"><h2 class="wl-h2" data-testid="folder-name">${esc(f.name || 'This folder')}</h2><div class="wl-path" data-testid="folder-path">${esc(f.root)}</div>${added}
126
+ <div class="wl-actions"><button type="button" class="wl-btn" data-testid="add-folder" ${S.busy ? 'disabled' : ''}>${S.busy === 'add' ? spin('Waiting for the chooser') : 'Add another folder'}</button></div>
127
+ ${S.note ? `<p class="wl-p wl-muted" data-testid="note">${esc(S.note)}</p>` : ''}</section>
128
+ <p class="wl-promise" data-testid="promise">Reads only. Writes nothing in your folder. Nothing leaves this computer.</p>
129
+ <div class="wl-actions"><button type="button" class="wl-btn wl-primary" data-testid="scan-button" ${S.busy ? 'disabled' : ''}>${S.busy === 'scan' ? spin('Scanning your folder') : 'Scan'}</button>
130
+ <button type="button" class="wl-btn wl-quiet" data-testid="not-now" ${S.busy ? 'disabled' : ''}>Not now</button></div>
131
+ ${S.busy === 'scan' ? '<p class="wl-p wl-muted" data-testid="scan-wait">A large folder can take a minute or two. PMOS keeps working while it reads.</p>' : ''}`;
132
+ }
133
+
134
+ function viewLook() {
135
+ const L = S.look || {};
136
+ const items = (L.findings || []).slice(0, 10).map((f) => `<li data-testid="finding"><strong>${esc(f.title)}</strong>${f.why ? `<div class="wl-muted">${esc(f.why)}</div>` : ''}<div class="wl-ref" data-testid="finding-ref">${esc(ref((f.evidence || [])[0]))}</div></li>`).join('');
137
+ return `<h1 class="wl-h1">First look</h1>
138
+ <p class="wl-p" data-testid="first-look-sentence">${esc(L.sentence)}</p>
139
+ <section class="wl-card"><h2 class="wl-h2">What matters most</h2>${items ? `<ol class="wl-findings">${items}</ol>` : '<p class="wl-p wl-muted" data-testid="no-findings">Nothing in this folder needs you urgently.</p>'}</section>
140
+ <div class="wl-actions"><button type="button" class="wl-btn wl-primary" data-testid="continue-button" ${S.busy ? 'disabled' : ''}>${S.busy === 'structure' ? spin('Building your portfolio') : 'Continue'}</button></div>`;
141
+ }
142
+
143
+ function rowView(r) {
144
+ const id = r.proposal_id; const docs = r.docs || []; const open = !!S.open[id];
145
+ const opts = S.structure.boards.map((b) => `<option value="${esc(b.key)}" ${b.key === boardOfRow(r) ? 'selected' : ''}>${esc(b.name)}</option>`).join('');
146
+ return `<div class="wl-row" data-testid="row" data-id="${esc(id)}">
147
+ <input type="checkbox" class="wl-check" data-testid="row-check" data-id="${esc(id)}" aria-label="Import ${esc(S.titles[id])}" ${S.checked[id] ? 'checked' : ''} />
148
+ <div><input type="text" class="wl-input" data-testid="row-title" data-id="${esc(id)}" value="${esc(S.titles[id])}" aria-label="Title" />
149
+ <div class="wl-meta"><span class="wl-chip" data-testid="row-lane">${esc(r.lane)}</span><span class="wl-chip" data-testid="row-anchor">${esc(r.anchor || 'no anchor')}</span><span class="wl-chip" data-testid="row-stage">${esc(r.stage)}</span>
150
+ <button type="button" class="wl-btn wl-small" data-testid="row-docs-toggle" data-id="${esc(id)}" aria-expanded="${open}">${plural(docs.length, 'document', 'documents')}</button>
151
+ <select class="wl-input" data-testid="row-board" data-id="${esc(id)}" aria-label="Board">${opts}</select></div>
152
+ ${open ? `<ul class="wl-docs" data-testid="row-docs">${docs.map((d) => `<li>${esc(d.title && d.title !== d.file ? d.title + ' · ' : '')}${esc(d.file)}</li>`).join('')}</ul>` : ''}</div></div>`;
153
+ }
154
+
155
+ function viewPortfolio() {
156
+ const all = rowsOf(); const c = counts();
157
+ const boards = S.structure.boards.map((b) => {
158
+ const mine = all.filter((r) => boardOfRow(r) === b.key);
159
+ return `<section class="wl-card wl-board" data-testid="board" data-board="${esc(b.key)}"><header><h2 class="wl-h2" data-testid="board-name">${esc(b.name)}</h2>
160
+ <span class="wl-muted" data-testid="unsorted-count">${plural((b.unsorted || []).length, 'document', 'documents')} unsorted</span></header>
161
+ ${mine.length ? mine.map(rowView).join('') : '<p class="wl-p wl-muted">No initiatives here.</p>'}</section>`;
162
+ }).join('');
163
+ return `<h1 class="wl-h1">Your portfolio</h1>
164
+ <p class="wl-p wl-muted">This is how PMOS would set up your work. Untick what you do not want, rename it, or move it to another board.</p>
165
+ ${boards}
166
+ <div class="wl-actions"><button type="button" class="wl-btn wl-primary" data-testid="import-button" ${S.busy || !c.n ? 'disabled' : ''}>${S.busy === 'apply' ? spin('Importing') : `Import ${plural(c.n, 'initiative', 'initiatives')} into ${plural(c.m, 'board', 'boards')}`}</button></div>`;
167
+ }
168
+
169
+ function viewQuestions() {
170
+ const qs = (S.structure.questions || []).map((q) => {
171
+ const skipped = !!S.skipped[q.id];
172
+ const cur = S.answers[q.id] != null ? S.answers[q.id] : q.default;
173
+ const input = (q.options && q.options.length)
174
+ ? (q.options.length > 4 && !q.free
175
+ ? `<select class="wl-input" data-testid="question-select" data-id="${esc(q.id)}" ${skipped ? 'disabled' : ''}>${q.options.map((o) => { const v = typeof o === 'object' ? o.value : o; const t = typeof o === 'object' ? (o.label || o.value) : o; return `<option value="${esc(v)}" ${String(cur) === String(v) ? 'selected' : ''}>${esc(t)}</option>`; }).join('')}</select>`
176
+ : q.options.map((o) => { const v = typeof o === 'object' ? o.value : o; const t = typeof o === 'object' ? (o.label || o.value) : o; return `<label class="wl-label"><input type="radio" name="q-${esc(q.id)}" data-testid="question-option" data-id="${esc(q.id)}" value="${esc(v)}" ${String(cur) === String(v) ? 'checked' : ''} ${skipped ? 'disabled' : ''} /> ${esc(t)}</label>`; }).join('')
177
+ + (q.free ? `<label class="wl-label wl-free">${esc(q.free)} <input type="text" class="wl-input" data-testid="question-input" data-id="${esc(q.id)}" placeholder="KR-A1 or health:upkeep" value="${q.options.some((o) => String(typeof o === 'object' ? o.value : o) === String(cur)) ? '' : esc(cur)}" ${skipped ? 'disabled' : ''} /></label>` : ''))
178
+ : `<input type="text" class="wl-input" data-testid="question-input" data-id="${esc(q.id)}" value="${esc(cur)}" ${skipped ? 'disabled' : ''} />`;
179
+ return `<section class="wl-card wl-q" data-testid="question" data-id="${esc(q.id)}"><p class="wl-p"><strong>${esc(q.text)}</strong></p>${input}
180
+ ${q.options && q.options.length ? '' : `<p class="wl-p wl-muted">Default: ${esc(q.default)}</p>`}<button type="button" class="wl-btn wl-small" data-testid="question-skip" data-id="${esc(q.id)}" aria-pressed="${skipped}">${skipped ? 'Skipped. Use the default' : 'Skip'}</button></section>`;
181
+ }).join('');
182
+ const c = counts();
183
+ return `<h1 class="wl-h1">A few questions</h1><p class="wl-p wl-muted">The scan could not decide these. Each has a default, and you can skip any of them.</p>${qs}
184
+ <div class="wl-actions"><button type="button" class="wl-btn wl-primary" data-testid="questions-continue" ${S.busy ? 'disabled' : ''}>${S.busy === 'apply' ? spin('Importing') : `Import ${plural(c.n, 'initiative', 'initiatives')} into ${plural(c.m, 'board', 'boards')}`}</button></div>`;
185
+ }
186
+
187
+ function viewBriefing() {
188
+ const items = S.briefing.map((b) => `<li data-testid="briefing-item"><strong>${esc(b.title)}</strong>${b.why ? `<div class="wl-muted">${esc(b.why)}</div>` : ''}${b.initiative ? `<div class="wl-muted" data-testid="briefing-initiative">${esc(b.initiative.title)} · board ${esc(b.initiative.board)}</div>` : ''}<div class="wl-ref">${esc(ref((b.evidence || [])[0]))}</div></li>`).join('');
189
+ return `<h1 class="wl-h1">Your first Briefing</h1>
190
+ <p class="wl-p wl-muted">${S.briefing.length ? 'These need you first.' : 'Nothing needs you right now.'}</p>
191
+ <section class="wl-card">${items ? `<ol class="wl-findings">${items}</ol>` : '<p class="wl-p wl-muted" data-testid="no-briefing">Your portfolio is in. Open PMOS to see it.</p>'}</section>
192
+ <div class="wl-actions"><button type="button" class="wl-btn wl-primary" data-testid="sources-continue">Continue</button>
193
+ <button type="button" class="wl-btn wl-quiet" data-testid="undo-link" ${S.busy ? 'disabled' : ''}>${S.busy === 'undo' ? spin('Undoing') : 'Undo this import'}</button></div>`;
194
+ }
195
+
196
+ function sourceRow(x) {
197
+ const st = S.sources[x.source] || {};
198
+ const busy = S.busy === 'source:' + x.source;
199
+ const state = st.started ? '<span class="wl-muted" data-testid="source-started">Started in an agent session. Follow it in PMOS.</span>'
200
+ : st.skipped ? '<span class="wl-muted" data-testid="source-skipped">Skipped. You can bring it in later from Settings.</span>'
201
+ : `<button type="button" class="wl-btn" data-testid="source-bring" data-id="${esc(x.source)}" ${S.busy ? 'disabled' : ''}>${busy ? spin('Starting') : 'Bring in'}</button>
202
+ <button type="button" class="wl-btn wl-quiet" data-testid="source-skip" data-id="${esc(x.source)}" ${S.busy ? 'disabled' : ''}>Skip</button>`;
203
+ return `<li class="wl-source" data-testid="source-row" data-id="${esc(x.source)}"><div class="wl-source-text"><strong>${esc(x.name)}</strong><div class="wl-muted">${esc(x.adds)}</div>${st.error ? `<div class="wl-error" role="alert">${esc(st.error)}</div>` : ''}</div><div class="wl-source-actions">${state}</div></li>`;
204
+ }
205
+ function viewSources() {
206
+ return `<h1 class="wl-h1">Bring in more sources</h1>
207
+ <p class="wl-p wl-muted">Your folder is in. These sources are optional. Each one runs in an agent session with your own connection, reads only, and writes nothing to the tool. Nothing is read until you press Bring in.</p>
208
+ <section class="wl-card"><ul class="wl-sources">${MORE_SOURCES.map(sourceRow).join('')}</ul>
209
+ <p class="wl-p wl-muted" data-testid="sources-not-yet">Inbox and Slack have no import yet.</p></section>
210
+ <div class="wl-actions"><a class="wl-btn wl-primary" data-testid="open-pmos" href="/c3.html?ws=${encodeURIComponent(S.ws)}">Open PMOS</a></div>`;
211
+ }
212
+
213
+ const VIEWS = { welcome: viewWelcome, look: viewLook, portfolio: viewPortfolio, questions: viewQuestions, briefing: viewBriefing, sources: viewSources };
214
+
215
+ function render() {
216
+ const keepScroll = window.scrollY;
217
+ const stepsBar = `<ol class="wl-steps" data-testid="steps">${STEPS.filter(([k]) => k !== 'questions' || S.step === 'questions' || (S.structure && (S.structure.questions || []).length))
218
+ .map(([k, t]) => `<li ${k === S.step ? 'aria-current="step"' : ''} data-step="${k}">${t}</li>`).join('')}</ol>`;
219
+ const a = document.activeElement;
220
+ const was = a && root.contains(a) ? [a.getAttribute('data-testid'), a.getAttribute('data-id')] : null;
221
+ root.setAttribute('data-step', S.step);
222
+ root.innerHTML = stepsBar + (S.error ? `<div class="wl-error" role="alert" data-testid="error">${esc(S.error)}</div>` : '') + VIEWS[S.step]();
223
+ window.scrollTo(0, keepScroll);
224
+ if (was && was[0]) { // keep the keyboard where it was after a re-render
225
+ const again = [...root.querySelectorAll('[data-testid="' + was[0] + '"]')].find((el) => (el.getAttribute('data-id') || null) === (was[1] || null));
226
+ if (again && !again.disabled) again.focus();
227
+ }
228
+ }
229
+
230
+ /* ── events (one delegate; the markup is rebuilt on each render) ─────────────────────────────────────────────────── */
231
+ const idOf = (el) => el.getAttribute('data-id');
232
+ const bringIn = (source) => run('source:' + source, async () => {
233
+ const r = await pmPost('/api/sources/refresh', { ws: S.ws, source });
234
+ S.sources[source] = r && r.ok ? { started: true } : { error: (r && r.error) || 'Starting the import did not work.' };
235
+ });
236
+ document.addEventListener('click', (e) => {
237
+ if (!root.contains(e.target)) return;
238
+ const t = e.target.closest('[data-testid]'); if (!t) return;
239
+ const k = t.getAttribute('data-testid');
240
+ if (k === 'add-folder') addFolder();
241
+ else if (k === 'scan-button') scan();
242
+ else if (k === 'continue-button') toPortfolio();
243
+ else if (k === 'import-button' || k === 'questions-continue') { if (k === 'questions-continue') run('apply', applyNow); else importNow(); }
244
+ else if (k === 'undo-link') undo();
245
+ else if (k === 'sources-continue') { S.step = 'sources'; S.error = ''; render(); }
246
+ else if (k === 'source-bring') bringIn(idOf(t));
247
+ else if (k === 'source-skip') { S.sources[idOf(t)] = { skipped: true }; render(); }
248
+ else if (k === 'not-now') notNow();
249
+ else if (k === 'row-docs-toggle') { S.open[idOf(t)] = !S.open[idOf(t)]; render(); }
250
+ else if (k === 'question-skip') { const id = idOf(t); S.skipped[id] = !S.skipped[id]; render(); }
251
+ });
252
+ root.addEventListener('change', (e) => {
253
+ const t = e.target; const k = t.getAttribute && t.getAttribute('data-testid');
254
+ if (k === 'row-check') { S.checked[idOf(t)] = t.checked; render(); }
255
+ else if (k === 'row-board') { S.boardOf[idOf(t)] = t.value; render(); }
256
+ else if (k === 'question-option' || k === 'question-select') S.answers[idOf(t)] = t.value;
257
+ });
258
+ root.addEventListener('input', (e) => {
259
+ const t = e.target; const k = t.getAttribute && t.getAttribute('data-testid');
260
+ if (k === 'row-title') S.titles[idOf(t)] = t.value;
261
+ else if (k === 'question-input') {
262
+ const id = idOf(t); const q = ((S.structure && S.structure.questions) || []).find((x) => x.id === id);
263
+ S.answers[id] = t.value.trim() ? t.value : (q ? q.default : t.value); // an emptied free answer goes back to the default
264
+ }
265
+ });
266
+
267
+ /* ── start ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
268
+ async function start() {
269
+ render();
270
+ const list = await pmGet('/api/workspaces');
271
+ const all = (list && list.workspaces) || [];
272
+ const mine = all.find((w) => w.id === S.ws) || (!S.ws ? all[0] : null);
273
+ if (!mine) { S.error = 'This folder is not open in PMOS. Start pmos in the folder, then reload.'; render(); return; }
274
+ S.ws = mine.id; S.folder = { name: mine.name, root: mine.root };
275
+ const st = await pmGet(`/api/onboarding/status?ws=${encodeURIComponent(S.ws)}`);
276
+ if (st && st.ok && st.state === 'imported') {
277
+ const live = (st.imports || []).filter((i) => !i.retracted).pop();
278
+ S.importId = live ? live.import_id : '';
279
+ if (S.importId && await loadBriefing() && await loadStructure()) S.step = 'briefing';
280
+ }
281
+ render();
282
+ }
283
+ start();
@@ -0,0 +1,20 @@
1
+ <!doctype html>
2
+ <html lang="en" class="wl-page" data-pm-page-theme="light">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <meta name="pmos-nonce" content="" />
7
+ <title>Welcome to PMOS</title>
8
+ <!-- The first-run page. The server fills the nonce meta the way it does for /c3.html, so a press (Scan, Import, Undo)
9
+ carries it. Tokens and fonts are the console's, so light and dark both resolve. The page does not load the console. -->
10
+ <link rel="preload" href="/vendor/ibm-plex-mono-400.woff2" as="font" type="font/woff2" crossorigin />
11
+ <link rel="stylesheet" href="/pm/tokens.css" />
12
+ <link rel="stylesheet" href="/pm/components.css" />
13
+
14
+ </head>
15
+ <body class="wl-body">
16
+ <main id="wl-root" class="wl-wrap" data-testid="welcome-root"></main>
17
+ <noscript><p class="wl-wrap">This page needs JavaScript.</p></noscript>
18
+ <script type="module" src="/pm/welcome.js"></script>
19
+ </body>
20
+ </html>
@@ -1942,6 +1942,21 @@ const autoSync = require('./lib/auto-sync').createAutoSync({
1942
1942
  onDone: (root) => boardCache.invalidate(root),
1943
1943
  label: `${os.hostname()} (auto-sync)`,
1944
1944
  });
1945
+ // workspace-data-freshness (R2 to R4): the facts cache is kept in the runtime state folder, and ONE change detector per
1946
+ // workspace starts the board and facts rebuilds when the repository changes, so nothing is re-read on a clock. The board
1947
+ // keeps its last value while it rebuilds (stale while revalidate): dropping it would show a blank Board on every change.
1948
+ const factsAsync = require('./lib/facts-async.js');
1949
+ factsAsync.configure({ stateDir: STATE });
1950
+ const freshness = require('./lib/freshness.js').create({
1951
+ stateDir: STATE,
1952
+ workspaces: () => [...workspaces.values()],
1953
+ schedules: () => readSchedules(),
1954
+ factsStatus: (root) => factsAsync.status(root),
1955
+ onChange: (w) => { boardCache.board(w.root, { refreshNow: true }); factsAsync.refresh(w.root); },
1956
+ pollMs: Math.max(250, Number(process.env.PMOS_FRESH_POLL_MS) || 10000),
1957
+ stableMs: Math.max(0, Number(process.env.PMOS_FRESH_STABLE_MS) || 3000),
1958
+ });
1959
+ if (!REQUIRED_AS_LIBRARY) freshness.start();
1945
1960
  const STOPPED_STATES = ['closed', 'on-hold', 'cancelled'];
1946
1961
  function readLifecycle(w) {
1947
1962
  const latest = new Map();
@@ -2362,6 +2377,12 @@ const SEED_SETS = {
2362
2377
  // jira-wbs (J14): the agent step that reads the status of every pushed Jira ticket into each initiative's snapshot,
2363
2378
  // read-only. Daily at 06:30, so the morning brief (06:45) and every report read a status at most a day old.
2364
2379
  jira: [{ name: 'Jira status check', skill: 'jira-sync', cadence: 'daily', time: '06:30' }],
2380
+ // workspace-data-freshness (R8): the read-only calendar pull, before the morning brief (06:45) and again at midday, so
2381
+ // Briefing and Calendar never work from yesterday's events.
2382
+ calendar: [
2383
+ { name: 'Calendar pull (morning)', skill: 'calendar-sync', cadence: 'daily', time: '06:30' },
2384
+ { name: 'Calendar pull (midday)', skill: 'calendar-sync', cadence: 'daily', time: '12:30' },
2385
+ ],
2365
2386
  };
2366
2387
  function readSchedules() {
2367
2388
  try { const a = JSON.parse(fs.readFileSync(schedFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; }
@@ -4065,6 +4086,18 @@ function myWorkRead(wsId) {
4065
4086
  if (!s || s.state === 'exited') return { ok: false, status: 500, error: (s && s.note) || 'the read session could not start' };
4066
4087
  return { ok: true, session: s.id };
4067
4088
  }
4089
+ /** Refresh now (workspace-data-freshness R7): start skill `skillId` in a session of repository `wsId`, as myWorkRead does. */
4090
+ function startSkillSession(wsId, skillId, name) {
4091
+ const w = getWs(wsId);
4092
+ if (!w) return { ok: false, status: 400, error: 'no workspace' };
4093
+ const task = taskById(skillId, w.root);
4094
+ if (!task || task.kind !== 'agent' || !task.prompt) return { ok: false, status: 409, error: `the ${skillId} skill is not installed for this repository` };
4095
+ const prompt = skillStore.retargetFiling(task.prompt, filingContract(w));
4096
+ const s = spawnSession({ name, agent: task.agent || 'claude', ws: w.id, origin: 'source-refresh', initialInput: prompt + '\r',
4097
+ modelAsk: { scheduleTier: null, skillTier: task.tier || null, skill: task.skill } });
4098
+ if (!s || s.state === 'exited') return { ok: false, status: 500, error: (s && s.note) || 'the refresh session could not start' };
4099
+ return { ok: true, session: s.id };
4100
+ }
4068
4101
  /* The calendar the Agenda remembers (G23): one file beside the briefs, written only by the brief route. */
4069
4102
  const CAL_MEMORY = () => path.join(STATE, 'calendar-memory.json');
4070
4103
  // the PM's key-meeting marks (lib/calendar-memory.js setMark): a small file beside the calendar memory, written only by
@@ -5514,12 +5547,12 @@ async function handleRequest(req, res) {
5514
5547
  const built = BUILD_RE.exec(url.pathname);
5515
5548
  const p = built ? built[1] : url.pathname;
5516
5549
 
5517
- if (p === '/' || p === '/index.html' || p === '/c3.html') {
5550
+ if (p === '/' || p === '/index.html' || p === '/c3.html' || p === '/welcome' || p === '/welcome.html') {
5518
5551
  // pm-os-absorb Run 2 (R25, X11): the per-launch nonce goes into the base meta line, nowhere else. no-store, so a
5519
5552
  // restarted server never answers a press from a page that holds the previous launch's value. The page code is
5520
5553
  // referenced under this launch's build prefix.
5521
5554
  const n = pmGuard.launchNonce();
5522
- return fs.readFile(path.join(PUB, p === '/c3.html' ? 'c3.html' : 'index.html'), 'utf8', (err, html) => {
5555
+ return fs.readFile(path.join(PUB, p === '/c3.html' ? 'c3.html' : (p === '/welcome' || p === '/welcome.html') ? 'welcome.html' : 'index.html'), 'utf8', (err, html) => {
5523
5556
  if (err) { res.writeHead(404); res.end('not found'); return; }
5524
5557
  let out = withBuild(html);
5525
5558
  if (n) out = out.replace('<meta name="' + pmGuard.NONCE_META + '" content="" />', '<meta name="' + pmGuard.NONCE_META + '" content="' + n + '" />');
@@ -6457,7 +6490,9 @@ async function handleRequest(req, res) {
6457
6490
  // `ledgers` (pm-os-absorb Run 1) — the plan-items signature of every registered workspace, so an open Breakdown
6458
6491
  // knows to refetch: `items` is one stat per workspace; `evidence` is computed only for the ids in ?ledgers=
6459
6492
  // (at most 4). Computed on this request and never stored (D33).
6460
- if (p === '/api/sessions') return json(res, { sessions: sessionList(), version: APP_VERSION,
6493
+ // `data_versions` (workspace-data-freshness R4): a number per workspace that moves when its files change, so a page
6494
+ // refetches only then.
6495
+ if (p === '/api/sessions') return json(res, { sessions: sessionList(), version: APP_VERSION, data_versions: freshness.versions(),
6461
6496
  ledgers: planItems.ledgerSigs([...workspaces.values()].map((w) => ({ id: w.id, root: w.root })), url.searchParams.get('ledgers')) });
6462
6497
  if (p === '/api/git/branches') {
6463
6498
  // Read-only branch awareness for a workspace. A non-repo returns {ok:false} (never a fake
@@ -8654,7 +8689,9 @@ async function handleRequest(req, res) {
8654
8689
  if (await routeModules.dispatch(req, res, url, { json, readBody, getWs, invalidateNeedsAgg, boardCache, consoleState, stateDirFor, sessionList, initiativeIndex,
8655
8690
  runtimeDir: STATE, spawnSession, jevModelOpts, sendToSession,
8656
8691
  // initiative my-work: every registered repository, the day runs, and the two presses that start sessions
8657
- workspaceList: () => [...workspaces.values()], readDayRun, myWorkBrief, myWorkRead })) return;
8692
+ workspaceList: () => [...workspaces.values()], readDayRun, myWorkBrief, myWorkRead,
8693
+ // workspace-data-freshness: the source table, the facts rebuild and Refresh now's skill sessions
8694
+ freshness, factsRefresh: (root) => factsAsync.refresh(root, { force: true }), startSkill: startSkillSession })) return;
8658
8695
  res.writeHead(404); res.end('not found');
8659
8696
  }
8660
8697
 
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: calendar-sync
3
+ label: "Calendar pull"
4
+ description: "Read, read-only, the PM's calendar events for today and the next 14 days, and hand them to PMOS so Briefing and Calendar work from today's events. It writes nothing to the calendar."
5
+ run: agent
6
+ agent: claude
7
+ output:
8
+ - Imported
9
+ - Not reached
10
+ - Needs you
11
+ ---
12
+
13
+ /calendar-sync — bring the PM's calendar up to date in PMOS. You read the calendar with the PM's own calendar connection. PMOS saves what you read and shows it on Briefing and Calendar. You change nothing in the calendar.
14
+
15
+ Rules that always hold:
16
+ 1. READ-ONLY on the calendar. Use only the tools that list or read events. Never create, edit, move, accept, decline, delete or respond to an event, and never send an invitation or a message.
17
+ 2. THE ONLY WRITE is `pmos calendar import --from <file> --sensitivity <label>`, once, at the end. Never edit a repository file. Never commit.
18
+ 3. LIGHT ON CONTENT. For each event, give only its id, title, start, end, the attendees' display names and, when you can tell, the initiative id it is for. Never copy a description, a meeting link, a dial-in number, an attachment, an email address or a token.
19
+ 4. SENSITIVITY. Use `internal` unless the PM said otherwise. Leave out any event marked private, personal, confidential or HR, and count it.
20
+ 5. TEMPORARY FILES OUTSIDE THE REPOSITORY. Write the events file in this session's scratch folder, and delete it after the import.
21
+ 6. NOTHING INVENTED. If the calendar cannot be reached, list it under "Not reached" and import nothing: an empty import is not a calendar with no meetings.
22
+
23
+ Procedure:
24
+ 1. List the PM's events from the start of today to the end of the 14th day after today, from the primary calendar. Fetch every page. Skip an event the PM declined.
25
+ 2. Write the events as JSON in the scratch folder, for example `<scratch>/calendar.json`, as a list with one object per event:
26
+ `{"id", "title", "start", "end", "attendees": ["Display Name", …], "initiative": "<initiative id or null>"}`
27
+ `start` and `end` are ISO times with the offset, for example `2026-10-12T09:00:00+07:00`. An all-day event has the date only.
28
+ 3. Run `pmos calendar import --from <scratch>/calendar.json --sensitivity internal`. If it refuses the file, fix what it names and run it once more.
29
+ 4. Delete the scratch file.
30
+ 5. Report in four short lists: Imported (the count, and the first and last day), Left out (the count of private events), Not reached, and Needs you (only when something blocked the pull).
@@ -0,0 +1,15 @@
1
+ {
2
+ "_comment": "Initiative onboarding-scan-and-map, slice 2: the first-run routes (in-process) and the /welcome page (browser).",
3
+ "modules": [
4
+ "routes-onboarding.test.js",
5
+ "onboarding-scripts.test.js"
6
+ ],
7
+ "panels": [
8
+ "pm-welcome-test.js"
9
+ ],
10
+ "seconds": {
11
+ "routes-onboarding.test.js": 6,
12
+ "pm-welcome-test.js": 25,
13
+ "onboarding-scripts.test.js": 8
14
+ }
15
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "_comment": "workspace-data-freshness: the shared commit range, the change-keyed facts cache, the change detector and data version, the source table and Refresh now, the worker-thread folds, and pmos calendar.",
3
+ "modules": ["data-freshness.test.js"],
4
+ "panels": [],
5
+ "seconds": { "data-freshness.test.js": 25 }
6
+ }