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,167 @@
1
+ 'use strict';
2
+ /* freshness.js — one change detector per workspace, a data version the pages watch, and how old each source is
3
+ * (workspace-data-freshness R4, R6, R7).
4
+ *
5
+ * create({ stateDir, workspaces, onChange, schedules, factsStatus, now, pollMs, stableMs, watcher }) → {
6
+ * start(), stop(), tick(), the watcher (lib/repo-watch.js), one per server
7
+ * versions() → { <ws id>: n } the number moves when that workspace's files change
8
+ * sources(w) → [row] one row per source, oldest information first
9
+ * enabled(root) · setEnabled(root, on) the PM's off switch, per repository (default on)
10
+ * }
11
+ * SOURCES the source table: id, label, skill, stale_after_ms
12
+ *
13
+ * THE DETECTOR. lib/repo-watch.js polls the repository signature (HEAD, porcelain status, dirty file times) every pollMs
14
+ * and runs `check` once the signature has held still for stableMs. Here `check` bumps the workspace's data version and
15
+ * calls onChange(w), which the server uses to start the board and facts rebuilds. Async git, read-only verbs, no network.
16
+ * A first look sets the data version but calls no onChange: a server that starts does not read every repository at once,
17
+ * and each cache is read on its first request (the facts from their disk entry when the repository has not changed).
18
+ *
19
+ * A ROW. { id, label, as_of: ISO|null, state: 'fresh'|'stale'|'never'|'building'|'failed', detail, stale_after_ms,
20
+ * skill: id|null, schedule: { cadence, time, day?, next_run }|null }. A source with no reading is `never`: it is never
21
+ * shown as fresh because nothing is known about it.
22
+ *
23
+ * Nothing here calls a tracker, a calendar or a network. The external sources are read by agent sessions with the PM's
24
+ * own connections; this module only reads the files those sessions leave behind.
25
+ */
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const repoWatch = require('./repo-watch.js');
29
+
30
+ const H = 3600 * 1000;
31
+ const SOURCES = Object.freeze([
32
+ { id: 'repository', label: 'Repository', skill: null, stale_after_ms: null },
33
+ { id: 'initiatives', label: 'Initiatives', skill: null, stale_after_ms: null },
34
+ { id: 'calendar', label: 'Calendar', skill: 'calendar-sync', stale_after_ms: 8 * H },
35
+ { id: 'jira', label: 'Jira status', skill: 'jira-sync', stale_after_ms: 26 * H },
36
+ { id: 'my-work', label: 'My work', skill: 'my-work-read', stale_after_ms: 26 * H },
37
+ { id: 'tracker', label: 'Tracker items', skill: 'tracker-refresh', stale_after_ms: 8 * 24 * H },
38
+ ]);
39
+
40
+ const iso = (ms) => (ms ? new Date(ms).toISOString() : null);
41
+ const mtime = (f) => { try { return fs.statSync(f).mtimeMs; } catch { return 0; } };
42
+ const readJson = (f, dflt) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return dflt; } };
43
+
44
+ /** The newest `fetched_at` among the imported calendar events (scripts/calendar-import.js ledger), in ms, or 0. */
45
+ function calendarAsOf(stateDir) {
46
+ let text = '';
47
+ try { text = fs.readFileSync(path.join(stateDir, 'calendar-import', 'events.jsonl'), 'utf8'); } catch { return 0; }
48
+ let best = 0;
49
+ for (const line of text.split('\n')) {
50
+ if (!line.includes('fetched_at')) continue;
51
+ try {
52
+ const o = JSON.parse(line);
53
+ const t = Date.parse((o.event && o.event.fetched_at) || o.fetched_at || '');
54
+ if (t > best) best = t;
55
+ } catch { /* a torn line is skipped */ }
56
+ }
57
+ return best;
58
+ }
59
+
60
+ /** The newest Jira status snapshot of a repository (lib/jira-status.js writes <state>/tracker/jira/<initiative>.md). */
61
+ function jiraAsOf(repoStateDir) {
62
+ if (!repoStateDir) return 0;
63
+ const dir = path.join(repoStateDir, 'tracker', 'jira');
64
+ let names = [];
65
+ try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.md') && !n.endsWith('-tickets.md')); } catch { return 0; }
66
+ return names.reduce((m, n) => Math.max(m, mtime(path.join(dir, n))), 0);
67
+ }
68
+
69
+ function create(o) {
70
+ const now = o.now || Date.now;
71
+ const settingsFile = o.stateDir ? path.join(o.stateDir, 'freshness.json') : null;
72
+ const versions = new Map(); // ws id -> n
73
+ const seen = new Map(); // root -> { checked_at, changed_at }
74
+ let settings = settingsFile ? readJson(settingsFile, { repos: {} }) : { repos: {} };
75
+ if (!settings || typeof settings.repos !== 'object') settings = { repos: {} };
76
+
77
+ const enabled = (root) => !(settings.repos[root] && settings.repos[root].watch === false);
78
+ function setEnabled(root, on) {
79
+ settings = { repos: { ...settings.repos, [root]: { watch: !!on } } };
80
+ if (settingsFile) {
81
+ try {
82
+ fs.mkdirSync(path.dirname(settingsFile), { recursive: true });
83
+ const tmp = `${settingsFile}.${process.pid}.tmp`;
84
+ fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
85
+ fs.renameSync(tmp, settingsFile);
86
+ } catch { return false; }
87
+ }
88
+ return true;
89
+ }
90
+
91
+ const watcher = (o.watcher || repoWatch.createWatcher)({
92
+ repos: () => o.workspaces().map((w) => ({ id: w.id, root: w.root })),
93
+ enabled,
94
+ // a folder that is not a checkout: the times and sizes of its top-level files
95
+ listFiles: async (root) => ({ files: (() => { try { return fs.readdirSync(root).filter((n) => !n.startsWith('.')).slice(0, 2000); } catch { return []; } })() }),
96
+ check: async ({ root }) => {
97
+ const t = now();
98
+ const prev = seen.get(root) || {};
99
+ seen.set(root, { checked_at: t, changed_at: t, first: !prev.checked_at });
100
+ for (const w of o.workspaces()) {
101
+ if (w.root !== root) continue;
102
+ versions.set(w.id, (versions.get(w.id) || 0) + 1);
103
+ if (!prev.checked_at) continue; // a first look only records the signature: the caches warm on their first read
104
+ try { if (o.onChange) o.onChange(w); } catch { /* a cache that cannot be refreshed is refreshed by its own rule */ }
105
+ }
106
+ return { ok: true };
107
+ },
108
+ pollMs: o.pollMs || 10000,
109
+ stableMs: o.stableMs == null ? 3000 : o.stableMs,
110
+ sweepEveryMs: 24 * H,
111
+ });
112
+
113
+ function scheduleOf(skill) {
114
+ if (!skill || !o.schedules) return null;
115
+ let list = [];
116
+ try { list = o.schedules() || []; } catch { return null; }
117
+ const s = list.find((x) => x && x.skill === skill && x.enabled !== false);
118
+ return s ? { cadence: s.cadence || null, time: s.time || null, day: s.day || null, next_run: s.nextRun || null, last_run: s.lastRun || null, last_status: s.lastStatus || null } : null;
119
+ }
120
+
121
+ function row(src, asOfMs, extra = {}) {
122
+ const t = now();
123
+ let state = asOfMs ? 'fresh' : 'never';
124
+ if (asOfMs && src.stale_after_ms && t - asOfMs > src.stale_after_ms) state = 'stale';
125
+ const schedule = scheduleOf(src.skill);
126
+ if (schedule && schedule.last_status === 'fail' && state !== 'never') state = 'failed';
127
+ return { id: src.id, label: src.label, as_of: iso(asOfMs), state, detail: null, stale_after_ms: src.stale_after_ms, skill: src.skill, schedule, ...extra };
128
+ }
129
+
130
+ /** One row per source for workspace `w` ({ id, root, repoStateDir }). Reads memory, small files and file times only. */
131
+ function sources(w) {
132
+ const S = Object.fromEntries(SOURCES.map((s) => [s.id, s]));
133
+ const out = [];
134
+ const sn = seen.get(w.root);
135
+ const watching = enabled(w.root);
136
+ // a watched repository is checked every few seconds, so it is as fresh as now; `changed_at` is the last change seen
137
+ out.push(row(S.repository, sn ? (watching ? now() : sn.checked_at) : 0, {
138
+ state: !watching ? 'off' : sn ? 'fresh' : 'never',
139
+ changed_at: sn ? iso(sn.changed_at) : null,
140
+ detail: !watching ? 'change detection is off for this repository' : sn ? 'checked every few seconds; the last change was read at once' : 'not looked at yet',
141
+ }));
142
+ const fs0 = o.factsStatus ? o.factsStatus(w.root) : null;
143
+ const factsAt = fs0 && fs0.as_of ? Date.parse(fs0.as_of) : 0;
144
+ let fstate = factsAt ? 'fresh' : 'never';
145
+ if (fs0 && fs0.building) fstate = 'building';
146
+ else if (fs0 && fs0.error) fstate = 'failed';
147
+ else if (factsAt && sn && sn.changed_at > factsAt) fstate = 'stale';
148
+ out.push(row(S.initiatives, factsAt, {
149
+ state: fstate,
150
+ detail: fs0 && fs0.error ? fs0.error : fs0 && fs0.took_ms != null ? `read in ${(fs0.took_ms / 1000).toFixed(1)} s` : null,
151
+ }));
152
+ out.push(row(S.calendar, o.stateDir ? calendarAsOf(o.stateDir) : 0));
153
+ out.push(row(S.jira, jiraAsOf(w.repoStateDir)));
154
+ const mw = o.stateDir ? readJson(path.join(o.stateDir, 'my-work', 'read.json'), null) : null;
155
+ out.push(row(S['my-work'], mw && mw.read_at ? Date.parse(mw.read_at) || 0 : 0));
156
+ const tr = scheduleOf('tracker-refresh');
157
+ out.push(row(S.tracker, tr && tr.last_run ? Date.parse(tr.last_run) || 0 : 0));
158
+ return out;
159
+ }
160
+
161
+ return {
162
+ start: () => watcher.start(), stop: () => watcher.stop(), tick: () => watcher.tick(),
163
+ versions: () => Object.fromEntries(versions), sources, enabled, setEnabled,
164
+ };
165
+ }
166
+
167
+ module.exports = { create, SOURCES, calendarAsOf, jiraAsOf };
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+ /* import-overlay.js: where a workspace's first-run import lives, and the fold of it (slice 2 of onboarding-scan-and-map).
3
+ * overlayDir(stateRoot, wsId) = <stateRoot>/imports/<wsId> overlayDirFor is the same function
4
+ * importedFor(stateRoot, wsId) -> { boards, initiatives, imports } from scripts/onboarding/apply.js; empty shapes when nothing is there.
5
+ * Initiative rows carry import_id and source: 'import'; board rows too. Never throws on a missing folder or file. */
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ /** Where scripts/onboarding/ is: the checkout's own, else the copy an installed package vendors under lib/vendor/
10
+ * (scripts/vendor-workspace-engine.js vendorTree). Null when neither is there. */
11
+ function onboardingDir() {
12
+ for (const d of [path.join(__dirname, '..', '..', 'scripts', 'onboarding'), path.join(__dirname, 'vendor', 'scripts', 'onboarding')]) {
13
+ if (fs.existsSync(path.join(d, 'scan.js'))) return d;
14
+ }
15
+ return null;
16
+ }
17
+
18
+ const WS_ID = /^[A-Za-z0-9_-]{1,64}$/; // one safe path segment (server ids are 10 hex characters)
19
+ const empty = () => ({ boards: [], initiatives: [], imports: [] });
20
+
21
+ function overlayDir(stateRoot, wsId) {
22
+ if (!WS_ID.test(String(wsId))) throw new Error('bad workspace id');
23
+ return path.join(stateRoot, 'imports', String(wsId));
24
+ }
25
+
26
+ function importedFor(stateRoot, wsId) {
27
+ let apply;
28
+ try { apply = require(path.join(onboardingDir(), 'apply.js')); } catch { return empty(); }
29
+ try { return apply.importedFor(overlayDir(stateRoot, wsId)); } catch { return empty(); }
30
+ }
31
+
32
+ module.exports = { overlayDir, overlayDirFor: overlayDir, importedFor, onboardingDir };
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+ /* offloop.js — run a synchronous fold in a worker thread, so a request does not hold the server (workspace-data-freshness R5).
3
+ *
4
+ * call(module, fn, args) → Promise<result> `module` is a file in workspace/lib; `fn` one of its exports; `args` and the
5
+ * result must survive structured clone (plain data).
6
+ * fallback(module, fn, args) → result the same call in this thread
7
+ * stop() end the worker (a test, or the server's exit)
8
+ *
9
+ * WHY. Board and My work fold every plan ledger and initiative record on each request: 0.2 to 0.5 s per workspace, all of
10
+ * it synchronous file reads. On the event loop that stalls every other request and every terminal's output for that long.
11
+ * In a worker it costs the same and stalls nothing.
12
+ *
13
+ * THE ANSWER IS THE SAME FUNCTION'S. The worker requires the same module and calls the same export with the same
14
+ * arguments; nothing is cached here. A worker that cannot start, crashes or times out rejects, and `run` then answers in
15
+ * this thread, so a route never fails because of the worker. One worker serves every call, one call at a time in order.
16
+ * Only modules in workspace/lib and only names in ALLOWED can be called.
17
+ */
18
+ const path = require('path');
19
+
20
+ const LIB = __dirname;
21
+ const ALLOWED = Object.freeze({ 'board-fold.js': ['boardFor'] });
22
+ const TIMEOUT_MS = 60000;
23
+
24
+ let worker = null;
25
+ let seq = 0;
26
+ const pending = new Map(); // id -> { resolve, reject, timer }
27
+
28
+ function allowed(mod, fn) { return Object.prototype.hasOwnProperty.call(ALLOWED, mod) && ALLOWED[mod].includes(fn); }
29
+
30
+ const WORKER_SRC = `
31
+ const { parentPort } = require('worker_threads');
32
+ const path = require('path');
33
+ parentPort.on('message', (m) => {
34
+ try {
35
+ const out = require(path.join(${JSON.stringify(LIB)}, m.mod))[m.fn](...m.args);
36
+ parentPort.postMessage({ id: m.id, ok: true, out });
37
+ } catch (e) {
38
+ parentPort.postMessage({ id: m.id, ok: false, error: String((e && e.message) || e) });
39
+ }
40
+ });`;
41
+
42
+ function failAll(err) {
43
+ for (const [id, p] of pending) { clearTimeout(p.timer); p.reject(err); pending.delete(id); }
44
+ }
45
+
46
+ function ensure() {
47
+ if (worker) return worker;
48
+ const { Worker } = require('worker_threads');
49
+ worker = new Worker(WORKER_SRC, { eval: true });
50
+ worker.unref();
51
+ worker.on('message', (m) => {
52
+ const p = pending.get(m.id);
53
+ if (!p) return;
54
+ pending.delete(m.id); clearTimeout(p.timer);
55
+ if (m.ok) p.resolve(m.out); else p.reject(new Error(m.error));
56
+ });
57
+ worker.on('error', (e) => { worker = null; failAll(e); });
58
+ worker.on('exit', () => { worker = null; failAll(new Error('the worker ended')); });
59
+ return worker;
60
+ }
61
+
62
+ function call(mod, fn, args = []) {
63
+ if (!allowed(mod, fn)) return Promise.reject(new Error(`offloop: ${mod}#${fn} is not allowed`));
64
+ return new Promise((resolve, reject) => {
65
+ let w;
66
+ try { w = ensure(); } catch (e) { reject(e); return; }
67
+ const id = ++seq;
68
+ const timer = setTimeout(() => {
69
+ pending.delete(id); reject(new Error('offloop: timed out'));
70
+ // a fold that hangs takes the worker with it; the next call starts a new one
71
+ try { w.terminate(); } catch { /* already gone */ }
72
+ }, TIMEOUT_MS);
73
+ if (timer.unref) timer.unref();
74
+ pending.set(id, { resolve, reject, timer });
75
+ w.ref(); // keep the process up while a call is in flight
76
+ w.postMessage({ id, mod, fn, args });
77
+ }).finally(() => { if (worker && !pending.size) worker.unref(); });
78
+ }
79
+
80
+ function fallback(mod, fn, args = []) {
81
+ if (!allowed(mod, fn)) throw new Error(`offloop: ${mod}#${fn} is not allowed`);
82
+ return require(path.join(LIB, mod))[fn](...args);
83
+ }
84
+
85
+ /** The worker's answer, or this thread's when the worker fails. PMOS_OFFLOOP=0 answers in this thread (a suite that
86
+ * replaces a module's export in its own process needs the fold to see the replacement). */
87
+ async function run(mod, fn, args = []) {
88
+ if (process.env.PMOS_OFFLOOP === '0') return fallback(mod, fn, args);
89
+ try { return await call(mod, fn, args); } catch { return fallback(mod, fn, args); }
90
+ }
91
+
92
+ function stop() { if (worker) { const w = worker; worker = null; failAll(new Error('stopped')); return w.terminate(); } return Promise.resolve(); }
93
+
94
+ module.exports = { call, run, fallback, stop, ALLOWED };
@@ -47,6 +47,9 @@ const VERBS = Object.freeze({
47
47
  // Not a pm-os-absorb verb (run: null): initiative my-work added it through the same table.
48
48
  'my-work': Object.freeze({ module: 'my-work-cli.js', run: null, guide: 'my-work.md',
49
49
  help: 'pmos my-work list|show|submit … — the Jira tickets and WBS items assigned to you, ranked (pmos my-work help)' }),
50
+ // Not a pm-os-absorb verb (run: null): workspace-data-freshness added it through the same table.
51
+ calendar: Object.freeze({ module: 'calendar-cli.js', run: null, guide: 'data-freshness.md',
52
+ help: 'pmos calendar import|status — the calendar events an agent session read, saved for Briefing and Calendar (pmos calendar help)' }),
50
53
  launcher: Object.freeze({ module: 'launcher-cli.js', run: null, guide: 'launcher.md',
51
54
  help: 'pmos launcher on|off|status — a PMOS app and a login item, so the workspace starts without a terminal' }),
52
55
  });
@@ -60,7 +60,9 @@ const MODULES = Object.freeze([
60
60
  'routes-c3-life', // console3 Lifecycle lens panels: newest evaluation per initiative, corrections per run, evidence sources (read-only)
61
61
  'routes-c3-strategy', // console3 Calendar Quarter and Year panes: outcomes, north star, signals, synergy, bets (read-only)
62
62
  'routes-c3-reports', // console3 Reports dashboards: library, per-scope data, page-press layouts (layouts are the only write)
63
+ 'routes-onboarding', // onboarding-scan-and-map slice 2: first run (scan read-only, structure, import into the overlay, undo, first Briefing)
63
64
  'routes-hooks', // planning-flows run 7: CLI hook endpoints (session token, per-run JSONL)
65
+ 'routes-sources', // workspace-data-freshness: how old each source is, Refresh now, the change detector switch
64
66
  ]);
65
67
  const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
66
68
  const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;
@@ -24,6 +24,7 @@ const grantMint = require('./grant-mint.js');
24
24
  const factoryJoin = require('./factory-join.js');
25
25
  const planItems = require('./plan-items.js');
26
26
  const boardFold = require('./board-fold.js');
27
+ const offloop = require('./offloop.js');
27
28
 
28
29
  const BATCH_RE = /^[A-Za-z0-9][\w.-]{0,79}$/;
29
30
  const text = (v) => (typeof v === 'string' ? v : '');
@@ -46,7 +47,8 @@ async function handle(req, res, url, ctx) {
46
47
  return true;
47
48
  }
48
49
  let out;
49
- try { out = boardFold.boardFor({ id: w.id, root: w.root }, { filter: url.searchParams.get('filter') || 'active' }); }
50
+ // in a worker thread (workspace-data-freshness R5): the same fold, without holding the server while it reads
51
+ try { out = await offloop.run('board-fold.js', 'boardFor', [{ id: w.id, root: w.root }, { filter: url.searchParams.get('filter') || 'active' }]); }
50
52
  catch (e) { ctx.json(res, { ok: false, error: 'the Board could not be read: ' + String((e && e.message) || 'error').slice(0, 160) }, 500); return true; }
51
53
  if (!out.ok) { ctx.json(res, { ok: false, error: out.error }, out.status || 400); return true; }
52
54
  ctx.json(res, { ...out, ws: w.id });
@@ -22,6 +22,7 @@ const path = require('path');
22
22
  const guard = require('./pm-guard.js');
23
23
  const myWork = require('./my-work.js');
24
24
  const boardFold = require('./board-fold.js');
25
+ const offloop = require('./offloop.js');
25
26
  const { localDate } = require('./brief.js');
26
27
 
27
28
  const dirOf = (ctx) => path.join(ctx.runtimeDir, 'my-work');
@@ -37,16 +38,33 @@ function loadActions(ctx) {
37
38
 
38
39
  /** The whole list, as GET answers it. */
39
40
  function listFor(ctx, now) {
41
+ const all = typeof ctx.workspaceList === 'function' ? ctx.workspaceList() : [];
42
+ return foldList(ctx, now, all.map((w) => {
43
+ try { return { w, b: boardFold.boardFor({ id: w.id, root: w.root }, { filter: 'mine', now }) }; }
44
+ catch (e) { return { w, error: e }; }
45
+ }));
46
+ }
47
+
48
+ /* The same list with each repository's Board fold run in a worker thread (workspace-data-freshness R5): the GET answers
49
+ * what listFor answers, without holding the server for a fold per repository. */
50
+ async function listForAsync(ctx, now) {
51
+ const all = typeof ctx.workspaceList === 'function' ? ctx.workspaceList() : [];
52
+ const folds = [];
53
+ for (const w of all) {
54
+ try { folds.push({ w, b: await offloop.run('board-fold.js', 'boardFor', [{ id: w.id, root: w.root }, { filter: 'mine', now }]) }); }
55
+ catch (e) { folds.push({ w, error: e }); }
56
+ }
57
+ return foldList(ctx, now, folds);
58
+ }
59
+
60
+ function foldList(ctx, now, folds) {
40
61
  const today = localDate(now);
41
62
  const banner = [];
42
63
  const boards = [];
43
- const all = typeof ctx.workspaceList === 'function' ? ctx.workspaceList() : [];
44
- for (const w of all) {
45
- try {
46
- const b = boardFold.boardFor({ id: w.id, root: w.root }, { filter: 'mine', now });
47
- if (b.ok) boards.push({ ws: w.id, repo: w.name || path.basename(w.root), board: b });
48
- else banner.push({ source: 'wbs:' + w.id, error: b.error });
49
- } catch (e) { banner.push({ source: 'wbs:' + w.id, error: String((e && e.message) || e).slice(0, 160) }); }
64
+ for (const { w, b, error } of folds) {
65
+ if (error) { banner.push({ source: 'wbs:' + w.id, error: String((error && error.message) || error).slice(0, 160) }); continue; }
66
+ if (b.ok) boards.push({ ws: w.id, repo: w.name || path.basename(w.root), board: b });
67
+ else banner.push({ source: 'wbs:' + w.id, error: b.error });
50
68
  }
51
69
  const read = loadRead(ctx);
52
70
  const actions = loadActions(ctx);
@@ -71,7 +89,7 @@ async function handle(req, res, url, ctx) {
71
89
  const now = Date.now();
72
90
  if (p === '/api/my-work') {
73
91
  if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
74
- ctx.json(res, listFor(ctx, now));
92
+ ctx.json(res, await listForAsync(ctx, now));
75
93
  return true;
76
94
  }
77
95
  if (!['/api/my-work/read', '/api/my-work/act', '/api/my-work/refresh'].includes(p)) return false;
@@ -119,4 +137,4 @@ async function handle(req, res, url, ctx) {
119
137
  return true;
120
138
  }
121
139
 
122
- module.exports = { prefixes: ['/api/my-work'], handle, listFor };
140
+ module.exports = { prefixes: ['/api/my-work'], handle, listFor, listForAsync };
@@ -0,0 +1,215 @@
1
+ 'use strict';
2
+ /* routes-onboarding.js — the first run: scan a folder read-only, show the proposed portfolio, import it into the overlay
3
+ * (onboarding-scan-and-map, slice 2). Every read takes ?ws. Every POST passes pm-guard requirePagePress.
4
+ *
5
+ * GET /api/onboarding/status?ws → { ok, state: 'new'|'scanned'|'imported', first_run, dismissed, scanning, scan_id, imported: { boards, initiatives }, imports }
6
+ * POST /api/onboarding/scan { ws, extra_roots? } → { ok, scan_id, first_look: { sentence, numbers, findings } }
7
+ * GET /api/onboarding/structure?ws → { ok, ...structure }
8
+ * POST /api/onboarding/apply { ws, include, revisions, answers } → { ok, import_id, boards, initiatives }
9
+ * POST /api/onboarding/undo { ws, import_id } → { ok, retracted }
10
+ * GET /api/onboarding/briefing?ws → { ok, items }
11
+ * POST /api/onboarding/dismiss { ws } → { ok } "Not now": status.first_run turns false for this folder
12
+ *
13
+ * STATE: <STATE>/imports/<wsId>/ (brief.json, scan.json, firstlook.json, proposals.jsonl, imports.jsonl), in the per-user
14
+ * state folder and never in the scanned folder. The scan reads ws.root (and the root of each extra_roots entry that is a
15
+ * registered workspace) and writes nothing there. Apply writes to the overlay only.
16
+ *
17
+ * WHO WRITES: the press gives the page's actor ({ by: 'pm:<slug>', via: 'ui' }). The onboarding ledger wants a human actor,
18
+ * so humanOf() maps 'pm:<slug>' to 'human:<slug>'; an actor that is not a PM press is refused. Modules under
19
+ * scripts/onboarding/ are required inside the handlers, so a module that is not built yet answers 500 for its route only.
20
+ *
21
+ * EXTRA ROOTS. scan() reads one root. An extra root must be a registered workspace (never an arbitrary path); it is scanned
22
+ * on its own into its own overlay, so its own Welcome page shows it. The answer lists them in `extra`.
23
+ */
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const guard = require('./pm-guard.js');
27
+
28
+ // the checkout's scripts/onboarding/, or the copy an installed package vendors under lib/vendor/ (import-overlay onboardingDir)
29
+ const obDir = () => { const d = require('./import-overlay.js').onboardingDir(); if (!d) throw new Error('the onboarding modules are not installed'); return d; };
30
+ const ob = (name) => require(path.join(obDir(), name));
31
+ const stateRootOf = (ctx) => (ctx && (ctx.runtimeDir || ctx.stateRoot)) || require('./state-root.js').defaultRoot();
32
+ const overlayOf = (ctx, w) => require('./import-overlay.js').overlayDir(stateRootOf(ctx), w.id);
33
+ const today = () => new Date().toISOString().slice(0, 10);
34
+ const bad = (status, error) => ({ status, body: { ok: false, error } });
35
+ const len = (v) => (Array.isArray(v) ? v.length : Number(v) || 0);
36
+
37
+ /** pm:<slug> from the press becomes human:<slug> for the onboarding ledger. Null for anything else. */
38
+ function humanOf(actor) {
39
+ const by = actor && typeof actor.by === 'string' ? actor.by : '';
40
+ return /^pm:[a-z0-9_-]{1,40}$/.test(by) ? { by: 'human:' + by.slice(3), via: actor.via || 'ui' } : null;
41
+ }
42
+
43
+ function wsOf(ctx, id) {
44
+ if (typeof id !== 'string' || !id) return null;
45
+ const w = ctx.getWs(id);
46
+ return w && w.id === id ? w : null;
47
+ }
48
+ const readJsonFile = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
49
+
50
+ function loadScan(dir) {
51
+ const scan = readJsonFile(path.join(dir, 'scan.json'));
52
+ return scan ? { scan, brief: readJsonFile(path.join(dir, 'brief.json')) } : null;
53
+ }
54
+ function imported(dir) {
55
+ const r = ob('apply.js').importedFor(dir) || {};
56
+ const imports = Array.isArray(r.imports) ? r.imports : [];
57
+ return { boards: len(r.boards), initiatives: len(r.initiatives), imports, live: imports.some((i) => !i.retracted) };
58
+ }
59
+
60
+ function status(ctx, w) {
61
+ const dir = overlayOf(ctx, w);
62
+ const have = loadScan(dir);
63
+ const imp = have ? imported(dir) : { boards: 0, initiatives: 0, imports: [], live: false };
64
+ const state = !have ? 'new' : imp.live ? 'imported' : 'scanned';
65
+ // first_run: `pmos` opens Welcome on its own only for a plain folder (no PMOS state folder of its own) that is not imported and
66
+ // where the PM did not choose "Not now". A PMOS repository, or a kit tenant, opens the console as before; `pmos welcome` still works.
67
+ let ownState = false; try { ownState = typeof ctx.stateDirFor === 'function' && !!ctx.stateDirFor(w); } catch { ownState = false; }
68
+ const dismissed = fs.existsSync(path.join(dir, 'dismissed.json'));
69
+ const first_run = state !== 'imported' && !dismissed && !ownState;
70
+ return { status: 200, body: { ok: true, state, first_run, dismissed, scanning: running.has(w.id), scan_id: have ? have.scan.scan_id : null, imported: { boards: imp.boards, initiatives: imp.initiatives }, imports: imp.imports } };
71
+ }
72
+
73
+ /* The scan runs in a child process (scripts/onboarding/scan-worker.js): a large folder takes a minute or more, and the server
74
+ * holds the PM's terminals, so it must never wait on it. A second press for the same workspace joins the running scan. */
75
+ const running = new Map();
76
+ function scanOne(ctx, w, now) {
77
+ if (running.has(w.id)) return running.get(w.id);
78
+ const dir = overlayOf(ctx, w);
79
+ const p = new Promise((resolve, reject) => {
80
+ const cp = require('child_process');
81
+ const child = cp.spawn(process.execPath, [path.join(obDir(), 'scan-worker.js'), w.root, dir, now], { stdio: ['ignore', 'pipe', 'pipe'] });
82
+ let out = ''; let err = '';
83
+ child.stdout.on('data', (d) => { out += d; });
84
+ child.stderr.on('data', (d) => { err += d; });
85
+ child.on('error', reject);
86
+ child.on('close', (code) => {
87
+ if (code !== 0) return reject(new Error(err.trim() || 'the scan stopped with code ' + code));
88
+ const look = readJsonFile(path.join(dir, 'firstlook.json'));
89
+ let id = null; try { id = JSON.parse(out.trim().split('\n').pop()).scan_id; } catch { /* read below */ }
90
+ resolve({ scan_id: id || (readJsonFile(path.join(dir, 'scan.json')) || {}).scan_id, first_look: look });
91
+ });
92
+ }).finally(() => running.delete(w.id));
93
+ running.set(w.id, p);
94
+ return p;
95
+ }
96
+
97
+ async function scanRoute(ctx, w, body) {
98
+ const extra = body.extra_roots == null ? [] : body.extra_roots;
99
+ if (!Array.isArray(extra) || extra.some((x) => typeof x !== 'string')) return bad(400, 'extra_roots must be a list of folder paths');
100
+ const known = typeof ctx.workspaceList === 'function' ? ctx.workspaceList() : [];
101
+ const extras = [];
102
+ for (const r of extra) {
103
+ const root = path.resolve(r);
104
+ const hit = known.find((k) => k && k.root && path.resolve(k.root) === root);
105
+ if (!hit) return bad(400, 'extra_roots may name registered workspaces only: ' + r);
106
+ if (hit.id !== w.id) extras.push(hit);
107
+ }
108
+ const now = today();
109
+ let out;
110
+ try { out = await scanOne(ctx, w, now); } catch (e) { return bad(500, 'the scan failed: ' + String((e && e.message) || e).slice(0, 200)); }
111
+ out.extra = [];
112
+ for (const x of extras) {
113
+ try { out.extra.push({ ws: x.id, scan_id: (await scanOne(ctx, x, now)).scan_id }); } catch (e) { out.extra.push({ ws: x.id, error: String((e && e.message) || e).slice(0, 120) }); }
114
+ }
115
+ return { status: 200, body: { ok: true, ...out } };
116
+ }
117
+
118
+ function structureOf(dir) {
119
+ const have = loadScan(dir);
120
+ if (!have) return null;
121
+ return ob('structure.js').structure(have.scan, { brief: have.brief });
122
+ }
123
+
124
+ function structureRoute(ctx, w) {
125
+ const dir = overlayOf(ctx, w);
126
+ let s;
127
+ try { s = structureOf(dir); } catch (e) { return bad(500, 'the structure could not be built: ' + String((e && e.message) || e).slice(0, 200)); }
128
+ if (!s) return bad(409, 'scan first: there is no scan for this folder yet');
129
+ return { status: 200, body: { ok: true, ...s } };
130
+ }
131
+
132
+ function applyRoute(ctx, w, body, actor) {
133
+ const dir = overlayOf(ctx, w);
134
+ let s;
135
+ try { s = structureOf(dir); } catch (e) { return bad(500, 'the structure could not be built: ' + String((e && e.message) || e).slice(0, 200)); }
136
+ if (!s) return bad(409, 'scan first: there is no scan for this folder yet');
137
+ const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
138
+ if (body.include != null && (!Array.isArray(body.include) || body.include.some((x) => typeof x !== 'string'))) return bad(400, 'include must be a list of proposal ids');
139
+ if (body.revisions != null && !isObj(body.revisions)) return bad(400, 'revisions must be an object keyed by proposal id');
140
+ if (body.answers != null && !isObj(body.answers)) return bad(400, 'answers must be an object keyed by question id');
141
+ const ids = new Set(s.boards.flatMap((b) => b.initiatives.map((r) => r.proposal_id)));
142
+ const unknown = (body.include || []).find((x) => !ids.has(x));
143
+ if (unknown) return bad(400, 'include names an unknown proposal: ' + unknown);
144
+ const include = body.include == null ? s.boards.flatMap((b) => b.initiatives.filter((r) => r.checked).map((r) => r.proposal_id)) : body.include;
145
+ try {
146
+ const r = ob('apply.js').apply(dir, s, { include, revisions: body.revisions || {}, answers: body.answers || {}, actor, now: new Date().toISOString(), root: w.root });
147
+ return { status: 200, body: { ok: true, import_id: r.import_id, boards: len(r.boards), initiatives: len(r.initiatives) } };
148
+ } catch (e) { return bad(400, String((e && e.message) || e).slice(0, 240)); }
149
+ }
150
+
151
+ function undoRoute(ctx, w, body, actor) {
152
+ const dir = overlayOf(ctx, w);
153
+ if (typeof body.import_id !== 'string' || !body.import_id) return bad(400, 'import_id is required');
154
+ const hit = imported(dir).imports.find((i) => i.import_id === body.import_id);
155
+ if (!hit) return bad(404, 'no such import');
156
+ if (hit.retracted) return bad(409, 'this import is already undone');
157
+ try {
158
+ const r = ob('apply.js').undo(dir, body.import_id, { actor, now: new Date().toISOString() });
159
+ return { status: 200, body: { ok: true, retracted: len(r.retracted) } };
160
+ } catch (e) { return bad(400, String((e && e.message) || e).slice(0, 240)); }
161
+ }
162
+
163
+ function briefingRoute(ctx, w) {
164
+ const dir = overlayOf(ctx, w);
165
+ const have = loadScan(dir);
166
+ if (!have) return bad(409, 'scan first: there is no scan for this folder yet');
167
+ const imp = imported(dir);
168
+ if (!imp.live) return bad(409, 'nothing is imported yet');
169
+ try {
170
+ const r = ob('first-briefing.js').firstBriefing(have.scan, ob('apply.js').importedFor(dir), { today: today() });
171
+ return { status: 200, body: { ok: true, items: r.items || [] } };
172
+ } catch (e) { return bad(500, 'the briefing could not be built: ' + String((e && e.message) || e).slice(0, 200)); }
173
+ }
174
+
175
+ /** "Not now": Welcome stops opening on its own for this folder. One file in the overlay; `pmos welcome` still opens it. */
176
+ function dismissRoute(ctx, w, body, actor) {
177
+ const dir = overlayOf(ctx, w);
178
+ fs.mkdirSync(dir, { recursive: true });
179
+ fs.writeFileSync(path.join(dir, 'dismissed.json'), JSON.stringify({ at: new Date().toISOString(), by: actor.by }) + '\n');
180
+ return { status: 200, body: { ok: true } };
181
+ }
182
+
183
+ const GETS = { status: status, structure: structureRoute, briefing: briefingRoute };
184
+ const POSTS = { scan: scanRoute, apply: applyRoute, undo: undoRoute, dismiss: dismissRoute };
185
+
186
+ module.exports = {
187
+ prefixes: ['/api/onboarding'],
188
+ humanOf, status, dismissRoute, scanRoute, structureRoute, applyRoute, undoRoute, briefingRoute,
189
+ handle: async (req, res, url, ctx) => {
190
+ const m = url.pathname.match(/^\/api\/onboarding\/([a-z]+)$/);
191
+ if (!m) return false;
192
+ const verb = m[1];
193
+ const isGet = Object.prototype.hasOwnProperty.call(GETS, verb);
194
+ const isPost = Object.prototype.hasOwnProperty.call(POSTS, verb);
195
+ if (!isGet && !isPost) return false;
196
+ const send = (r) => { ctx.json(res, r.body, r.status); return true; };
197
+ if (isGet) {
198
+ if (req.method !== 'GET') return send(bad(405, 'GET only'));
199
+ const w = wsOf(ctx, url.searchParams.get('ws'));
200
+ if (!w) return send(bad(404, 'no such workspace'));
201
+ return send(GETS[verb](ctx, w));
202
+ }
203
+ if (req.method !== 'POST') return send(bad(405, 'POST only'));
204
+ const got = await ctx.readJson(req);
205
+ if (!got.ok) return send(bad(400, 'invalid JSON body'));
206
+ const body = got.body || {};
207
+ const g = guard.requirePagePress(req, body);
208
+ if (!g.ok) return send(bad(g.status, g.error));
209
+ const w = wsOf(ctx, body.ws);
210
+ if (!w) return send(bad(404, 'no such workspace'));
211
+ const actor = humanOf(g.actor);
212
+ if (!actor) return send(bad(403, 'only the PM can do this, from the page'));
213
+ return send(await POSTS[verb](ctx, w, body, actor));
214
+ },
215
+ };