create-pmos 0.522.0 → 0.524.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/bin/create-pmos.js +26 -1
  2. package/package.json +57 -47
  3. package/payload/KIT-VERSION +1 -1
  4. package/payload/planning/evals/eval-calibration.md +1 -1
  5. package/payload/planning/okrs/health-budgets.md +1 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/workspace/changelog.json +56 -0
  8. package/workspace/guide/agent-capabilities.md +6 -3
  9. package/workspace/guide/home.md +1 -1
  10. package/workspace/guide/horizon-scan.md +2 -2
  11. package/workspace/guide/idle-lab.md +46 -0
  12. package/workspace/guide/index.md +2 -0
  13. package/workspace/guide/moments.md +43 -0
  14. package/workspace/guide/welcome.md +37 -28
  15. package/workspace/lib/bg-queue.js +226 -0
  16. package/workspace/lib/connector-read.js +19 -0
  17. package/workspace/lib/driver-run.js +8 -1
  18. package/workspace/lib/first-reveal.js +311 -0
  19. package/workspace/lib/headless.js +2 -2
  20. package/workspace/lib/idle-lab.js +275 -0
  21. package/workspace/lib/moments.js +241 -0
  22. package/workspace/lib/morning-goals.js +191 -0
  23. package/workspace/lib/onboard-fundamentals.js +205 -0
  24. package/workspace/lib/onboarding-progress.js +13 -1
  25. package/workspace/lib/orchestrator-spawn.js +9 -2
  26. package/workspace/lib/project-onboard.js +93 -22
  27. package/workspace/lib/proposals.js +9 -5
  28. package/workspace/lib/refresh-run.js +2 -1
  29. package/workspace/lib/route-modules.js +4 -0
  30. package/workspace/lib/routes-lab.js +137 -0
  31. package/workspace/lib/routes-loose.js +1 -0
  32. package/workspace/lib/routes-moments.js +42 -0
  33. package/workspace/lib/routes-morning-goals.js +109 -0
  34. package/workspace/lib/routes-onboard.js +45 -8
  35. package/workspace/lib/routes-proposals.js +12 -2
  36. package/workspace/lib/routes-reveal.js +73 -0
  37. package/workspace/lib/scan-run.js +4 -3
  38. package/workspace/lib/scan.js +5 -3
  39. package/workspace/package-lock.json +2 -2
  40. package/workspace/package.json +1 -1
  41. package/workspace/project-onboard-flow-test.js +70 -57
  42. package/workspace/public/pm/cozy-ev.css +3 -0
  43. package/workspace/public/pm/cozy-lab-ins.js +87 -0
  44. package/workspace/public/pm/cozy-lab.css +86 -0
  45. package/workspace/public/pm/cozy-lab.js +138 -0
  46. package/workspace/public/pm/cozy-nav.js +22 -8
  47. package/workspace/public/pm/cozy-onboard.css +31 -0
  48. package/workspace/public/pm/cozy-onboard.js +217 -111
  49. package/workspace/public/pm/cozy-reveal.css +52 -0
  50. package/workspace/public/pm/cozy-reveal.js +130 -0
  51. package/workspace/public/pm/home-app.js +38 -3
  52. package/workspace/public/pm/home-impact.css +12 -0
  53. package/workspace/public/pm/home-moment.js +41 -0
  54. package/workspace/public/pm/ini-props.js +15 -6
  55. package/workspace/public/pm/premium-board.js +2 -0
  56. package/workspace/public/pm/premium-first.js +1 -1
  57. package/workspace/public/pm/premium-insights.js +6 -1
  58. package/workspace/public/pm/quiet-place.js +31 -0
  59. package/workspace/public/premium-intelligence.html +3 -0
  60. package/workspace/replaced-tests.json +14 -0
  61. package/workspace/server.js +46 -5
  62. package/workspace/test-files.json +2 -0
  63. package/workspace/test-lists/first-reveal.json +9 -0
  64. package/workspace/test-lists/idle-lab.json +9 -0
  65. package/workspace/test-lists/morning-goals.json +9 -0
@@ -11,6 +11,8 @@
11
11
  * folderFacts(root, { git }) → what is in a folder: git root, PMOS, README, commits (no model call)
12
12
  * proposeFolder(parent, name) → a destination that does not overwrite anything
13
13
  * draftContext({ project, folder, search }) → the editable context; a field is filled only with provenance
14
+ * readContext(text) → project-context.md read back: { name, fields, jira }. Setup's fundamentals and
15
+ * later readers take About, the Jira key, the weekly and the goal draft from it
14
16
  * initialize({ plan, journalFile, kit, git }) → resumable, idempotent initialization of the chosen folder
15
17
  *
16
18
  * THE READ-ONLY BOUNDARY (requirement 7). The search run gets no built-in tools (`--tools ""`), user hooks off, slash
@@ -24,28 +26,15 @@ const os = require('os');
24
26
  const crypto = require('crypto');
25
27
  const cp = require('child_process');
26
28
 
27
- const READ_VERBS = new Set(['search', 'get', 'list', 'read', 'fetch', 'find', 'query', 'lookup', 'view', 'describe', 'retrieve', 'browse', 'resolve']);
28
- const WRITE_VERBS = new Set(['create', 'update', 'delete', 'add', 'post', 'send', 'write', 'edit', 'move', 'transition', 'comment', 'assign', 'upload',
29
- 'remove', 'set', 'merge', 'close', 'archive', 'share', 'copy', 'trash', 'label', 'mark', 'apply', 'schedule', 'reply', 'forward', 'draft', 'respond',
30
- 'invite', 'publish', 'run', 'execute', 'trigger', 'link', 'unlink', 'approve', 'submit', 'save', 'log', 'record', 'register', 'insert', 'append',
31
- 'clear', 'fork', 'enable', 'disable', 'untrash', 'unmark', 'unlabel', 'cancel', 'install', 'import', 'export', 'generate', 'use', 'push', 'request', 'rename']);
29
+ const { readTool } = require('./connector-read.js');
32
30
  const HOOKS_OFF = JSON.stringify({ disableAllHooks: true });
33
31
  const MAX_FINDINGS = 40;
34
- const FIELDS = ['purpose', 'team', 'owner', 'timeline', 'goals'];
32
+ const FIELDS = ['purpose', 'team', 'owner', 'timeline', 'weekly', 'goals'];
35
33
 
36
34
  const clean = (v, n = 300) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, n);
37
35
  const httpsOnly = (u) => { try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href.slice(0, 500) : null; } catch { return null; } };
38
36
  /** Claude Code's tool prefix for a server name: `mcp__<name with every other character as _>__`. */
39
37
  const prefixOf = (name) => 'mcp__' + String(name).replace(/[^A-Za-z0-9_-]/g, '_') + '__';
40
- const words = (s) => String(s).replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w.toLowerCase());
41
-
42
- /** A tool may be granted only when its own name (after the server prefix) leads with a read verb and holds no write verb. */
43
- function readTool(toolName, prefix) {
44
- const own = prefix && toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName.replace(/^mcp__[^_]+(?:_[^_]+)*?__/, '');
45
- const w = words(own);
46
- if (!w.length || w.some((x) => WRITE_VERBS.has(x))) return false;
47
- return w.slice(0, 3).some((x) => READ_VERBS.has(x));
48
- }
49
38
 
50
39
  function parseMcpList(stdout) {
51
40
  const out = [];
@@ -134,8 +123,16 @@ function promptFor(project, sources) {
134
123
  'Answer with ONE JSON object and nothing else:',
135
124
  '{"findings":[{"id":"f1","source":"<source name>","kind":"jira_project|issue|page|document|channel|other","title":"...","url":"https://...","summary":"one sentence"}],',
136
125
  ' "jira_projects":[{"key":"ABC","name":"...","url":"https://...","finding":"f1"}],',
137
- ' "fields":{"purpose":{"value":"...","finding":"f2"},"team":null,"owner":null,"timeline":null,"goals":null}}',
126
+ ' "fields":{"purpose":{"value":"...","finding":"f2"},"team":null,"owner":null,"timeline":null,"weekly":null,"goals":null},',
127
+ ' "highlights":[{"finding":"f2","headline":"...","detail":"..."}],',
128
+ ' "goal_draft":{"objective":"...","kr":"...","finding":"f2"},',
129
+ ' "facts":{"jira":{"key":"ABC","open":42,"mine":6,"due_week":2,"finding":"f1"},"weekly":{"title":"...","weekday":"Tue","time":"10:00","next":"YYYY-MM-DD","finding":"f3"},',
130
+ ' "goal":{"metric":"...","now":"61%","target":"68%","finding":"f2"},"timeline":{"start":"YYYY-MM-DD","end":"YYYY-MM-DD","finding":"f2"}}}',
138
131
  'Rules: report only what a tool returned. A field must cite the id of the finding it came from, else null. Never guess purpose, audience, goals, metrics, owners or dates. At most 40 findings.',
132
+ '"weekly" is a recurring meeting about the project found in a calendar: its name, day and time, for example "Checkout weekly · Tuesdays 10:00".',
133
+ '"highlights": at most 3, at most one per source, the most useful thing each source said about the project. headline: at most 8 words; detail: one short sentence (a quote of the purpose, a count of open issues and how many are assigned to the user, or the next date of a recurring meeting).',
134
+ '"goal_draft": ONE key result a PM could keep for this project, drafted from the purpose finding it cites: an objective in a few words and a key result that names what to measure. It is a draft for the PM to judge. Use null when no finding states a purpose.',
135
+ '"facts": counts and dates exactly as a tool returned them (open = unresolved issues of the Jira project, mine = of those assigned to the current user, due_week = of mine due in the next 7 days; weekly = a recurring meeting about the project), each citing its finding. Leave out any fact you did not read.',
139
136
  ].join('\n');
140
137
  }
141
138
  function jsonIn(text) {
@@ -183,8 +180,20 @@ async function search({ bound, project, sources, tools, spawnLines: sl = spawnLi
183
180
  const v = body && body.fields && body.fields[k];
184
181
  if (v && typeof v === 'object' && byId.has(v.finding) && clean(v.value)) fields[k] = { value: clean(v.value, 400), finding: v.finding };
185
182
  }
183
+ const facts = factsOf(body && body.facts, byId);
184
+ // the source tiles: at most one per source, each citing a finding of a source that answered
185
+ const highlights = [], tiled = new Set();
186
+ for (const h of (body && Array.isArray(body.highlights) ? body.highlights : [])) {
187
+ const f = h && byId.get(h.finding);
188
+ if (!f || tiled.has(f.source) || !clean(h.headline)) continue;
189
+ tiled.add(f.source);
190
+ highlights.push({ finding: f.id, source: f.source, source_label: f.source_label, headline: clean(h.headline, 80), detail: clean(h.detail, 200), url: f.url });
191
+ if (highlights.length === 3) break;
192
+ }
193
+ const g = body && body.goal_draft;
194
+ const goal = g && typeof g === 'object' && byId.has(g.finding) && clean(g.kr) ? { objective: clean(g.objective, 120), kr: clean(g.kr, 200), finding: g.finding } : null;
186
195
  return {
187
- ok: true, findings, jira, fields,
196
+ ok: true, findings, jira, fields, highlights, goal, facts,
188
197
  sources: sources.map((s) => {
189
198
  const p = per[s.id];
190
199
  if (!(tools[s.id] || []).length) return { id: s.id, label: s.label, state: 'failed', error: 'This login has no read access to search it.' };
@@ -195,6 +204,33 @@ async function search({ bound, project, sources, tools, spawnLines: sl = spawnLi
195
204
  };
196
205
  }
197
206
 
207
+ /** The numbers and dates the search read (onboarding B2: the portrait, lib/first-reveal.js). A fact is kept only when it cites a finding
208
+ * this run kept, with every value cut to its shape: a count is a whole number, a date is YYYY-MM-DD, text is one short line. */
209
+ const FACT_SHAPES = {
210
+ jira: { key: 'key', open: 'count', mine: 'count', due_week: 'count' },
211
+ weekly: { title: 'text', weekday: 'text', time: 'text', next: 'day' },
212
+ goal: { metric: 'text', now: 'text', target: 'text' },
213
+ timeline: { start: 'day', end: 'day' },
214
+ };
215
+ function factsOf(raw, byId) {
216
+ const out = {};
217
+ if (!raw || typeof raw !== 'object') return out;
218
+ for (const [k, shape] of Object.entries(FACT_SHAPES)) {
219
+ const v = raw[k];
220
+ if (!v || typeof v !== 'object' || !byId.has(v.finding)) continue;
221
+ const x = { finding: v.finding };
222
+ for (const [f, t] of Object.entries(shape)) {
223
+ const y = v[f];
224
+ if (t === 'count' && Number.isInteger(Number(y)) && Number(y) >= 0 && Number(y) < 1e6 && y !== '' && y !== null) x[f] = Number(y);
225
+ else if (t === 'day' && /^\d{4}-\d{2}-\d{2}$/.test(String(y || '')) && Number.isFinite(Date.parse(y + 'T00:00:00Z'))) x[f] = String(y);
226
+ else if (t === 'key' && /^[A-Z][A-Z0-9_]{1,19}$/.test(String(y || ''))) x[f] = String(y);
227
+ else if (t === 'text' && clean(y)) x[f] = clean(y, 120);
228
+ }
229
+ if (Object.keys(x).length > 1) out[k] = x;
230
+ }
231
+ return out;
232
+ }
233
+
198
234
  function gitRun(git, args, cwd) {
199
235
  try { return cp.execFileSync(git, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000, windowsHide: true }).trim(); }
200
236
  catch { return null; }
@@ -253,7 +289,7 @@ function draftContext({ project, folder, found }) {
253
289
  }
254
290
  if (!f.purpose && folder && folder.readme) f.purpose = { value: folder.readme.slice(0, 300), source: 'folder', title: folder.readme_file };
255
291
  if (!f.purpose && clean(project.description) && clean(project.description) !== clean(project.name)) f.purpose = you(project.description);
256
- for (const k of ['purpose', 'team', 'owner', 'timeline', 'goals']) if (!f[k]) f[k] = null;
292
+ for (const k of FIELDS) if (!f[k]) f[k] = null;
257
293
  return f;
258
294
  }
259
295
 
@@ -262,12 +298,43 @@ function contextMarkdown(ctx) {
262
298
  const f = ctx.fields || {};
263
299
  return ['---', 'type: Reference', `title: "Project context: ${String(ctx.name).replace(/"/g, "'")}"`, `timestamp: ${new Date().toISOString().slice(0, 10)}`, '---', '',
264
300
  `# ${ctx.name}`, '', 'Reviewed by the PM during PMOS setup. Facts without a source were left blank on purpose.', '',
265
- line('Purpose', f.purpose), line('Team', f.team), line('Owner', f.owner), line('Timeline', f.timeline), line('Goals and metrics', f.goals), line('Link', f.link), line('Code', f.code),
301
+ line('Purpose', f.purpose), line('Team', f.team), line('Owner', f.owner), line('Timeline', f.timeline), line('Weekly', f.weekly), line('Goals and metrics', f.goals), line('Link', f.link), line('Code', f.code),
266
302
  ctx.jira ? `- **Jira:** ${ctx.jira.key}${ctx.jira.name ? ' · ' + ctx.jira.name : ''}${ctx.jira.url ? ' (' + ctx.jira.url + ')' : ''}. Linked as a source; PMOS changed nothing in Jira.` : '- **Jira:** Not linked.',
303
+ ...(ctx.goal ? [`- **Goal draft:** ${ctx.goal.objective ? ctx.goal.objective.replace(/\n/g, ' ') + ' — ' : ''}${ctx.goal.kr.replace(/\n/g, ' ')} (draft${ctx.goal.source_label ? ', from ' + ctx.goal.source_label : ''})`] : []),
267
304
  '', ...(ctx.sources && ctx.sources.length ? ['## Sources read during setup', '', ...ctx.sources.map((s) => `- ${s.title}${s.url ? ' — ' + s.url : ''} (${s.source_label})`), ''] : []),
268
305
  ].join('\n') + '\n';
269
306
  }
270
307
 
308
+ const LABEL_KEY = { purpose: 'purpose', team: 'team', owner: 'owner', timeline: 'timeline', weekly: 'weekly', 'goals and metrics': 'goals', link: 'link', code: 'code' };
309
+ /** project-context.md read back (the file contextMarkdown writes, or the PM's edit of it). → { name, fields: { k: { value, source, source_label } }, jira, goal }.
310
+ * A line that says "Not known yet." is no field. PURE. */
311
+ function readContext(text) {
312
+ const s = String(text || '');
313
+ const out = { name: clean((/^#\s+(.+)$/m.exec(s) || [])[1] || '', 120), fields: {}, jira: null, goal: null };
314
+ for (const m of s.matchAll(/^- \*\*([^*:]+):\*\*\s*(.*)$/gm)) {
315
+ const label = m[1].trim().toLowerCase(), rest = m[2].trim();
316
+ if (label === 'jira') {
317
+ const j = /^([A-Z][A-Z0-9_]{1,19})\b(?:\s·\s([^(.]+?))?(?:\s\((https:[^)\s]+)\))?\./.exec(rest);
318
+ if (j) out.jira = { key: j[1], name: clean(j[2] || '', 120) || null, url: j[3] || null };
319
+ continue;
320
+ }
321
+ if (label === 'goal draft') {
322
+ const g = /^(?:(.+?) — )?(.+?) \(draft(?:, from ([^)]+))?\)$/.exec(rest);
323
+ if (g) out.goal = { objective: clean(g[1] || '', 120), kr: clean(g[2], 200), source_label: g[3] ? clean(g[3], 80) : null };
324
+ continue;
325
+ }
326
+ const k = LABEL_KEY[label];
327
+ if (!k || /^Not known yet\.?$/.test(rest)) continue;
328
+ const src = /^(.*?) \(source: (.+)\)$/.exec(rest);
329
+ const value = clean(src ? src[1] : rest, 400);
330
+ if (!value) continue;
331
+ const said = src ? src[2] : '';
332
+ out.fields[k] = said === 'the PM' ? { value, source: 'you' } : said === 'the folder' ? { value, source: 'folder' }
333
+ : said ? { value, source: 'finding', source_label: clean(said.split(',')[0], 80) } : { value, source: 'you' };
334
+ }
335
+ return out;
336
+ }
337
+
271
338
  function writeJson(file, v) {
272
339
  fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
273
340
  const tmp = file + '.' + crypto.randomBytes(6).toString('hex') + '.tmp';
@@ -282,7 +349,9 @@ function installKit(root, kit) {
282
349
  if (fs.existsSync(dest)) throw new Error('A pmos folder is already there. PMOS will not replace it.');
283
350
  if (kit.payload) { fs.cpSync(kit.payload, dest, { recursive: true }); }
284
351
  else if (kit.buildScript) { cp.execFileSync('bash', [kit.buildScript, root], { stdio: 'ignore', timeout: 300000 }); }
285
- else throw new Error('This PMOS install has no kit to add.');
352
+ // No npm fallback here: only lib/kit-update.js reaches the registry (no-network.test.js). An app installed before the kit shipped with
353
+ // it gets the kit with its next update.
354
+ else throw new Error('This PMOS install has no kit to add. Update PMOS in Settings › Updates, then try again.');
286
355
  if (!fs.existsSync(path.join(dest, 'AGENTS.md'))) throw new Error('The kit did not install completely.');
287
356
  const profile = path.join(root, '.pmos-profile');
288
357
  if (!fs.existsSync(profile)) fs.writeFileSync(profile, 'pm-artifacts\n# PMOS repository profile (D68): pm-artifacts | product-code\n');
@@ -290,8 +359,10 @@ function installKit(root, kit) {
290
359
  const cur = fs.existsSync(ga) ? fs.readFileSync(ga, 'utf8') : '';
291
360
  if (!cur.split('\n').some((l) => l.trim() === '*.jsonl merge=union')) fs.writeFileSync(ga, `${cur}${cur && !cur.endsWith('\n') ? '\n' : ''}*.jsonl merge=union\n`);
292
361
  }
362
+ /** Where the kit comes from: the installed app's own payload/ (create-pmos copies it there), the npx bundle's, a PMOS checkout's
363
+ * build script. */
293
364
  function kitSource(here = __dirname, env = process.env) {
294
- const candidates = [env.PMOS_KIT_PAYLOAD, path.join(here, '..', '..', 'payload'), path.join(here, '..', '..', 'npm-pkg', 'payload')].filter(Boolean);
365
+ const candidates = [env.PMOS_KIT_PAYLOAD, path.join(here, '..', 'payload'), path.join(here, '..', '..', 'payload'), path.join(here, '..', '..', 'npm-pkg', 'payload')].filter(Boolean);
295
366
  const payload = candidates.find((p) => fs.existsSync(path.join(p, 'AGENTS.md')));
296
367
  if (payload) return { payload };
297
368
  const buildScript = path.join(here, '..', '..', 'kit', 'build-kit.sh');
@@ -360,4 +431,4 @@ function initialize({ plan, journalFile, kit = kitSource(), git = 'git', now = (
360
431
  return { ok: true, root, journal: j };
361
432
  }
362
433
 
363
- module.exports = { discover, inventory, search, readTool, parseMcpList, prefixOf, folderFacts, proposeFolder, draftContext, contextMarkdown, initialize, kitSource, pmosIn, slug, spawnLines, LOCKED };
434
+ module.exports = { discover, inventory, search, factsOf, readTool, parseMcpList, prefixOf, folderFacts, proposeFolder, draftContext, contextMarkdown, readContext, initialize, kitSource, pmosIn, slug, spawnLines, LOCKED };
@@ -21,6 +21,8 @@
21
21
  * description the record's own description (initiative-description-field; filed by lib/description-props.js): type new (none yet) or
22
22
  * enrich (the PRD says more). data { text, from? }; one item { id: 'description', title: the text cut }. Always section 'check'.
23
23
  * ✓ writes the record's description line (lib/initiative-writer.js setDescription)
24
+ * prototyped · improved · scanned what the idle lab made while the PM was away (lib/idle-lab.js result): a first round of 2–3 specimens,
25
+ * a new round of one, a scan finding to attach as evidence. No items; always section 'check'. ✓ keeps it as a document
24
26
  * grill what Claude found when it grilled the initiative by itself (lib/grill.js): type = the angle, no items, a next step in data.next;
25
27
  * always section 'check' (its own ✓, never Keep all). 3 show, best rank first; the group's `more` counts the rest
26
28
  *
@@ -30,7 +32,7 @@
30
32
  * end { ini, run, ok, stopped? } the run ended (stopped: the PM pressed ■)
31
33
  * propose { ini, board, run, id, group, type, section, title, why, sources: [{ label, ref }], items: [item], data }
32
34
  * an agent filed it (`pmos propose …`); the last line for an id wins, so a newer run replaces what it files again
33
- * drop { ini, sig, facts? } ✕ on a proposal or on one item of it: gone until its evidence changes (a new signature) or the date
35
+ * drop { ini, sig, facts?, why? } why: the PM's reason, when given (the idle lab learns from it). ✕ on a proposal or on one item of it: gone until its evidence changes (a new signature) or the date
34
36
  * passes again. `facts` (batch 24 item 6) are what the ✕ rests on, not where the work sits: a date proposal's facts are its tasks
35
37
  * and the due dates they missed, a grill finding's are its angle and its sources. A ✕ holds while they hold, so a workflow switch
36
38
  * (new section keys, so new ids and signatures), a reworded reason or a new day (new computed dates) does not bring it back
@@ -64,6 +66,7 @@ const TYPES = { found: ['stage', 'docs', 'tasks', 'link', 'maybe', 'unplaced'],
64
66
  const SOURCES = ['drive', 'docs', 'sheets', 'slides', 'jira', 'calendar', 'notes', 'mail', 'slack', 'confluence', 'chat', 'repo'];
65
67
  const SOURCE_NAMES = { drive: 'Drive and Slides', docs: 'Docs', sheets: 'Sheets', slides: 'Slides', jira: 'Jira', calendar: 'Calendar and meeting notes', notes: 'Meeting notes',
66
68
  mail: 'Gmail', slack: 'Slack', confluence: 'Confluence', chat: 'Chat', repo: 'This repository' };
69
+ const LAB = ['prototyped', 'improved', 'scanned']; // the idle lab's results (lib/idle-lab.js result): no items, always section 'check'
67
70
  const CLOSED = new Set(['Done', "Won't do", 'Archived']);
68
71
  const RISK_DAYS = 3;
69
72
  const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
@@ -201,6 +204,7 @@ function tidyName(from, to) {
201
204
  function fold(lines) {
202
205
  const props = new Map(); // id → the newest propose line
203
206
  const drops = new Set(); // signatures
207
+ const dropWhy = new Map(); // signature → the PM's reason for the ✕, when one was given (the idle lab learns from it)
204
208
  const kept = []; // keep lines, oldest first
205
209
  const undone = new Set();
206
210
  const looks = new Map(); // ini → { run, why, at, ended, ok, stopped, sources: Map source → { state, n, what } }
@@ -214,11 +218,11 @@ function fold(lines) {
214
218
  else if (l.kind === 'src' && ini) { const L = looks.get(ini); if (L && L.run === l.run && str(l.source)) L.sources.set(l.source, { state: l.state === 'done' ? 'done' : 'reading', n: Number(l.n) || 0, what: str(l.what) }); }
215
219
  else if (l.kind === 'end' && ini) { const L = looks.get(ini); if (L && L.run === l.run) { L.ended = true; L.ok = !!l.ok; L.stopped = !!l.stopped; L.endAt = l.at || ''; } }
216
220
  else if (l.kind === 'propose' && ini && str(l.id)) props.set(l.id, { id: l.id, ini, board: str(l.board), run: str(l.run), group: l.group, type: l.type, section: str(l.section) || 'sure', title: str(l.title), why: str(l.why), sources: Array.isArray(l.sources) ? l.sources : [], items: Array.isArray(l.items) ? l.items.filter(isObj) : [], data: isObj(l.data) ? l.data : {}, at: l.at || '', by: l.by || null });
217
- else if (l.kind === 'drop' && str(l.sig)) { drops.add(l.sig); for (const f of Array.isArray(l.facts) ? l.facts : []) if (/^f:[0-9a-f]{10}$/.test(String(f))) drops.add(f); }
221
+ else if (l.kind === 'drop' && str(l.sig)) { drops.add(l.sig); if (str(l.why)) dropWhy.set(l.sig, one(l.why, 300)); for (const f of Array.isArray(l.facts) ? l.facts : []) if (/^f:[0-9a-f]{10}$/.test(String(f))) drops.add(f); }
218
222
  else if (l.kind === 'keep' && str(l.batch)) kept.push({ batch: l.batch, ini, board: str(l.board), ids: Array.isArray(l.ids) ? l.ids : [], wrote: Array.isArray(l.wrote) ? l.wrote.filter(isObj) : [], at: l.at || '', by: l.by || null });
219
223
  else if (l.kind === 'undo' && str(l.batch)) undone.add(l.batch);
220
224
  }
221
- return { props, drops, kept, undone, looks, epoch, t };
225
+ return { props, drops, dropWhy, kept, undone, looks, epoch, t };
222
226
  }
223
227
  /** The kept proposal ids (a batch undone gives its proposals back). */
224
228
  const keptIds = (F) => { const s = new Set(); for (const k of F.kept) if (!F.undone.has(k.batch)) for (const id of k.ids) s.add(id); return s; };
@@ -332,7 +336,7 @@ function datesOf(ini, T, o = {}) {
332
336
  }
333
337
 
334
338
  /* ── what r.props carries ── */
335
- const ORDER = { description: -2, outcome: -1, found: 0, dates: 1, status: 2, names: 3, links: 4, grill: 5 };
339
+ const ORDER = { description: -2, outcome: -1, prototyped: -0.6, improved: -0.5, scanned: -0.4, found: 0, dates: 1, status: 2, names: 3, links: 4, grill: 5 };
336
340
  const GRILL_SHOWN = 3;
337
341
  /**
338
342
  * One initiative's proposals, as the ✦ and the Viewer draw them, or null when there is nothing to show.
@@ -351,7 +355,7 @@ function viewOf(F, ini, o = {}) {
351
355
  p.items = p.items.filter((x) => !F.drops.has(itemSig(p, x)) && (!x.task || (T.has(x.task) && !(p.group !== 'found' && T.get(x.task).closed && p.group !== 'status'))));
352
356
  if (p.group === 'status') p.items = p.items.filter((x) => T.has(x.task) && !T.get(x.task).closed);
353
357
  if (p.group === 'names') p.items = p.items.filter((x) => T.has(x.task) && T.get(x.task).title !== x.to).map((x) => ({ ...x, from: T.get(x.task).title, ...(T.get(x.task).jira ? { jira: true } : {}) }));
354
- if (!p.items.length && p.type !== 'stage' && p.group !== 'grill') continue;
358
+ if (!p.items.length && p.type !== 'stage' && p.group !== 'grill' && !LAB.includes(p.group)) continue;
355
359
  if (p.group === 'grill' && factsOf(p).some((f) => F.drops.has(f))) continue;
356
360
  props.push(p);
357
361
  }
@@ -236,7 +236,8 @@ function create(host) {
236
236
  }
237
237
  start(src, ws, { trigger: d.trigger, slot: d.slot, note: d.note });
238
238
  }
239
- try { healTick(t, set); } catch (e) { warn('source repair', e); }
239
+ // PMOS_SOURCE_HEAL=off stops the repairs (a suite about pressed and scheduled runs sets it, so no repair run races them)
240
+ if (process.env.PMOS_SOURCE_HEAL !== 'off') { try { healTick(t, set); } catch (e) { warn('source repair', e); } }
240
241
  if (t - lastPrune > PRUNE_EVERY_MS) {
241
242
  lastPrune = t;
242
243
  try { const p = R.prune(dir, rules.keep, t); if (p.lines || p.copies) warn('refresh log', { message: `removed ${p.lines} lines and ${p.copies} copies older than ${rules.keep} days` }); } catch (e) { warn('refresh log prune', e); }
@@ -33,6 +33,7 @@ const path = require('path');
33
33
  const MODULES = Object.freeze([
34
34
  'routes-setup', // Claude-ready onboarding, explicit profile checks and real first work
35
35
  'routes-onboard', // project-first onboarding in the Cozy Home (claude-onboarding-build), bound to no workspace
36
+ 'routes-reveal', // onboarding B2: Home right after setup (the ticks, the product portrait, the drafted first initiative)
36
37
  'routes-initiatives', // Run 2 (2C): Roadmap, List, placements, new initiative, measurements
37
38
  'routes-today', // Run 3 (3B): GET /api/today
38
39
  'routes-capture', // Run 3 (3C): ⌘K capture
@@ -56,6 +57,7 @@ const MODULES = Object.freeze([
56
57
  'routes-board-grill', // premium-uncovered-features (Board): a new initiative's Why, material and first questions (page press; file mode)
57
58
  'routes-insights-premium', // briefing-premium-insights: the premium Insights page's model from the KR metric records and report files (read-only)
58
59
  'routes-forecast', // build brief Grill and Forecast F-A: a goal forecast's kept baseline, and the forecasts that turned
60
+ 'routes-morning-goals', // build brief Good morning GM1: the quarter strip, the PM's line, the morning snapshot, goal tags, what Yes does
59
61
  'routes-grill', // Grill and Forecast screens: Claude's checks for the pages (mode, cap, spend, each initiative's last run, Home's one line; read-only)
60
62
  'routes-home-desk', // premium-agreed-design (Home): seen marks, queued messages, the weekly update's recipients and sends, stakeholders (page press; file mode)
61
63
  'routes-live', // batch 6 item 2: the Live pill's sessions and the titles of their initiatives (read-only)
@@ -82,6 +84,7 @@ const MODULES = Object.freeze([
82
84
  'routes-task-plans', // batch 11 item 8: drafts of an initiative's tasks, saved plans, Make live (task-plans.jsonl; page-press POST)
83
85
  'routes-task-links', // batch 11 item 15: links between tasks, Claude's proposals, the quiet moves (task-links.jsonl; page-press POST)
84
86
  'routes-proposals', // batch 12 items 2, 3 and 4: everything Claude proposes on an initiative, under its one ✦ (proposals.jsonl; page-press POST)
87
+ 'routes-lab', // idle lab (build U): Intelligence › Lab, the compare view, the specimen files and the Lab's own ✦ (idle-lab.jsonl; page-press POST)
85
88
  'routes-loose', // batch 13 item 3: Claude's clean-up of Home's "Not in an initiative" under the group's ✦ (loose-props/; page-press POST)
86
89
  'routes-workflows', // workflows-per-initiative (D99): the workflow library, save, copy, archive and the chat's draft (workflows.jsonl; page-press POST)
87
90
  'routes-settings', // console3 Settings: the settings store for settings with no home of their own (GET; page-press POST)
@@ -116,6 +119,7 @@ const MODULES = Object.freeze([
116
119
  'routes-impact-views', // batch 16 item 7 (Your impact, Phase 4): the chapter card's read, a stage that closed this week (boards.jsonl; read-only)
117
120
  'routes-next-prompt', // batch 26 item 2: the chat's suggested next prompt and Sharper card (lib/next-prompt.js, through lib/ai-guard.js)
118
121
  'routes-drift', // weekly-drift-check: What would I have missed, Home's weekly ✦ and its ⋯ drop and Undo (runtime drift/drift.jsonl; page-press POST)
122
+ 'routes-moments', // onboarding B3 gradual exposure: the day's ✦ in Home's chat, the menu dots, a quiet place's one line (onboarding-progress record; page-press POST)
119
123
  ]);
120
124
  const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
121
125
  const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+ /* routes-lab.js — what the pages show of the idle lab (build U, idle-lab B+C 2026-11-04; canvas main, In development I1 IlBC, I3 IlCompare).
3
+ * The fold and the shapes are lib/idle-lab.js; a result on an initiative is a ✦ under its one blue ✦ (lib/routes-proposals.js keeps, drops
4
+ * and undoes it). This module answers Intelligence › Lab, the compare view and the specimen files, and the Lab's own ✦.
5
+ *
6
+ * GET /api/lab?ws&board → { ok, lab } lab: lib/idle-lab.js labOf (null: nothing ran this week and nothing waits)
7
+ * GET /api/lab/compare?ws&board&id[&key] → { ok, compare } lib/idle-lab.js compareOf (404 when the prototype is gone)
8
+ * GET /api/lab/specimen?file → text/html, a specimen from <runtime>/idle-lab/specimens/, served sandboxed (no scripts that can
9
+ * reach PMOS: CSP sandbox without allow-same-origin, no network but the fonts)
10
+ * POST /api/lab/keep { ws, id } → { ok, batch, said } ✓ on a Lab ✦: start keeps it and starts the initiative on its board
11
+ * POST /api/lab/drop { ws, id, why? } → { ok, batch } ✕, with the PM's reason when given
12
+ * POST /api/lab/undo { ws, batch } → { ok } Undo of a keep or a drop (a started initiative stays: close it on the Board)
13
+ *
14
+ * The week's record lists every background job: the idle lab, the grill runs (lib/grill.js), the AI jobs through lib/ai-guard.js, and
15
+ * whatever the shared queue reports (ctx.bgQueue.record({ ws, since }) → [{ job, title, meta?, at, ini?, board? }], when it is there).
16
+ * Every POST passes pm-guard requirePagePress. */
17
+ const fs = require('fs');
18
+ const guard = require('./pm-guard.js');
19
+ const LAB = require('./idle-lab.js');
20
+ const PR = require('./proposals.js');
21
+
22
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
23
+ const fail = (ctx, res, status, error) => { ctx.json(res, { ok: false, error }, status); return true; };
24
+ const WEEK = 7 * 864e5;
25
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/;
26
+ const JOB_NAMES = { 'past-repeat': 'You decided this before', 'decision-memory': 'Earlier decisions', outcomes: 'Expected outcomes', descriptions: 'Descriptions',
27
+ drift: 'What you might have missed', rehearse: 'Rehearse a plan', 'next-prompt': 'Next prompt', 'sharpen-prompt': 'Sharper prompt' };
28
+ const CSP = "sandbox allow-scripts; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com data:; img-src data: blob:";
29
+
30
+ function wsOf(ctx, id) { let w = null; try { w = ctx.getWs(id || null); } catch { w = null; } return w && (!id || w.id === id) ? w : null; }
31
+ const dirOf = (ctx, w) => { try { return ctx.stateDirFor ? ctx.stateDirFor(w) : null; } catch { return null; } };
32
+ /** The initiatives' names, from the rows the Board already read (none while they are still being read). */
33
+ function titles(w) {
34
+ const m = new Map();
35
+ try { const b = require('./routes-boards.js').baseRows(w); if (b && b.ok) for (const r of b.rows || []) if (r && r.id) m.set(r.id, { title: r.title || r.id, board: r.board || '' }); } catch { /* none */ }
36
+ return m;
37
+ }
38
+ /** What the other background jobs did this week, as record rows. Each source that cannot be read is left out. */
39
+ function others(ctx, w, dir, T, now) {
40
+ const out = [], since = now - WEEK, name = (ini) => (T.get(ini) || {}).title || ini;
41
+ try {
42
+ const G = require('./grill.js'), F = ctx.runtimeDir ? G.read(ctx.runtimeDir) : null;
43
+ for (const r of F ? F.runs.values() : []) if (r.ended && r.ok !== false && r.ws === w.id && (Date.parse(r.endAt || r.at) || 0) >= since) out.push({ job: 'grill', title: 'Grill · ' + name(r.ini), meta: 'why: ' + r.why, at: r.endAt || r.at, ini: r.ini, board: r.board || '' });
44
+ } catch { /* no grill log */ }
45
+ try {
46
+ // the AI jobs' audit (lib/ai-guard.js AUDIT), read as a file: this page files nothing through the guard
47
+ const audit = dir ? fs.readFileSync(require('path').join(dir, 'ai-audit.jsonl'), 'utf8').split('\n').slice(-400).map((s) => { try { return JSON.parse(s); } catch { return null; } }).filter(isObj) : [];
48
+ for (const l of audit) if (l.event === 'end' && (Date.parse(l.at) || 0) >= since) out.push({ job: l.job, title: (JOB_NAMES[l.job] || l.job), meta: l.filed != null ? l.filed + ' filed' : '', at: l.at });
49
+ } catch { /* no audit */ }
50
+ try { if (ctx.bgQueue && typeof ctx.bgQueue.record === 'function') for (const x of ctx.bgQueue.record({ ws: w.id, since }) || []) if (isObj(x) && x.job !== 'grill') out.push(x); /* grill runs are read above, from their own log */ } catch { /* the queue says nothing */ }
51
+ return out;
52
+ }
53
+
54
+ const READS = {
55
+ '/api/lab': (ctx, res, url) => {
56
+ const w = wsOf(ctx, url.searchParams.get('ws')); if (!w) return fail(ctx, res, 404, 'no such workspace');
57
+ const dir = dirOf(ctx, w); if (!dir) { ctx.json(res, { ok: true, lab: null }); return true; }
58
+ const now = Date.now(), T = titles(w);
59
+ const lab = LAB.labOf({ P: PR.read(dir), L: LAB.read(dir), extra: others(ctx, w, dir, T, now), now, titleOf: (ini) => (T.get(ini) || {}).title || '' });
60
+ ctx.json(res, { ok: true, lab });
61
+ return true;
62
+ },
63
+ '/api/lab/compare': (ctx, res, url) => {
64
+ const w = wsOf(ctx, url.searchParams.get('ws')); if (!w) return fail(ctx, res, 404, 'no such workspace');
65
+ const dir = dirOf(ctx, w), id = url.searchParams.get('id') || '';
66
+ if (!dir || !ID_RE.test(id)) return fail(ctx, res, 400, 'id names a prototype');
67
+ const T = titles(w);
68
+ const c = LAB.compareOf(PR.read(dir), id, { key: url.searchParams.get('key') || '', titleOf: (ini) => (T.get(ini) || {}).title || '' });
69
+ if (!c) return fail(ctx, res, 404, 'That prototype is no longer here');
70
+ for (const col of c.columns) if (col.file) col.url = LAB.specimenUrl(col.file);
71
+ ctx.json(res, { ok: true, compare: c });
72
+ return true;
73
+ },
74
+ '/api/lab/specimen': (ctx, res, url) => {
75
+ const f = LAB.specimenPath(ctx.runtimeDir, url.searchParams.get('file') || '');
76
+ let html = null;
77
+ try { html = f ? fs.readFileSync(f) : null; } catch { html = null; }
78
+ if (!html) return fail(ctx, res, 404, 'That specimen is not in the idle lab folder');
79
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'private, no-cache', 'Referrer-Policy': 'no-referrer' });
80
+ res.end(html);
81
+ return true;
82
+ },
83
+ };
84
+
85
+ const WRITES = {
86
+ '/api/lab/keep': (ctx, res, body, g, w, dir) => {
87
+ const F = LAB.read(dir), p = LAB.looseOpen(F).find((x) => x.id === body.id);
88
+ if (!p) return fail(ctx, res, 409, 'That is no longer waiting');
89
+ const wrote = [];
90
+ let said = 'Kept it.';
91
+ if (p.type === 'start') {
92
+ const r = require('./routes-loose.js').createIni(ctx, g, { ws: w.id, board: p.data.board }, p.data.title, p.data.anchor, p.why);
93
+ if (r.error) return fail(ctx, res, 409, r.error);
94
+ wrote.push(r.write); said = 'Started ' + p.data.title + '.';
95
+ }
96
+ const batch = PR.newBatch();
97
+ LAB.append(dir, g.actor, { kind: 'keep', id: p.id, batch, wrote });
98
+ ctx.json(res, { ok: true, batch, said });
99
+ return true;
100
+ },
101
+ '/api/lab/drop': (ctx, res, body, g, w, dir) => {
102
+ const p = LAB.looseOpen(LAB.read(dir)).find((x) => x.id === body.id);
103
+ if (!p) return fail(ctx, res, 409, 'That is no longer waiting');
104
+ const why = typeof body.why === 'string' ? body.why.replace(/\s+/g, ' ').trim().slice(0, 300) : '';
105
+ const batch = PR.newBatch();
106
+ LAB.append(dir, g.actor, { kind: 'drop', id: p.id, batch, ...(why ? { why } : {}) });
107
+ ctx.json(res, { ok: true, batch });
108
+ return true;
109
+ },
110
+ '/api/lab/undo': (ctx, res, body, g, w, dir) => {
111
+ const F = LAB.read(dir), b = String(body.batch || '');
112
+ const known = F.kept.some((k) => k.batch === b) || readsDrop(dir, b);
113
+ if (!known || F.undone.has(b)) return fail(ctx, res, 409, 'Nothing to undo');
114
+ LAB.append(dir, g.actor, { kind: 'undo', batch: b });
115
+ ctx.json(res, { ok: true });
116
+ return true;
117
+ },
118
+ };
119
+ const readsDrop = (dir, b) => LAB.readLines(LAB.ledgerFile(dir)).some((l) => l.kind === 'drop' && l.batch === b);
120
+
121
+ module.exports = {
122
+ prefixes: ['/api/lab'],
123
+ handle: async (req, res, url, ctx) => {
124
+ const p0 = url.pathname;
125
+ if (req.method === 'GET') { const fn = READS[p0]; if (!fn) return fail(ctx, res, 404, 'not found'); return fn(ctx, res, url); }
126
+ if (req.method !== 'POST') return fail(ctx, res, 405, 'GET or POST only');
127
+ const fn = WRITES[p0];
128
+ if (!fn) return fail(ctx, res, 404, 'not found');
129
+ const got = await ctx.readJson(req);
130
+ if (!got.ok || !isObj(got.body)) return fail(ctx, res, 400, 'the body must be a JSON object');
131
+ const g = guard.requirePagePress(req, got.body);
132
+ if (!g.ok) return fail(ctx, res, g.status, g.error);
133
+ const w = wsOf(ctx, typeof got.body.ws === 'string' ? got.body.ws : null); if (!w) return fail(ctx, res, 404, 'no such workspace');
134
+ const dir = dirOf(ctx, w); if (!dir) return fail(ctx, res, 409, 'This repository has no PMOS state folder. Nothing is saved until the folder exists.');
135
+ return fn(ctx, res, got.body, g, w, dir);
136
+ },
137
+ };
@@ -187,6 +187,7 @@ function dropLine(ctx, res, body, g, dir, kind) {
187
187
 
188
188
  module.exports = {
189
189
  prefixes: ['/api/loose-props'],
190
+ createIni, // the idle lab's Lab ✦ "start an initiative" uses the same Add an ask and Accept (lib/routes-lab.js)
190
191
  handle: async (req, res, url, ctx) => {
191
192
  const p = url.pathname;
192
193
  const dir = LP.dirOf(ctx.runtimeDir);
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+ /* routes-moments.js — a feature appears when its moment comes, over HTTP (onboarding build B3; lib/moments.js holds the rules).
3
+ *
4
+ * GET /api/moments → { ok, moment: { id, ws, product, text, sub, href, guide, guideLabel, go, shown } | null,
5
+ * places: { board|insights|reports: { quiet, dot, line } } }
6
+ * the day's ✦ for Home's chat, the menu dots, and the quiet line of a place not open yet
7
+ * POST /api/moments/shown { ws, id } → { ok } the ✦ was drawn: it is that day's moment
8
+ * POST /api/moments/keep { ws, id } → { ok } ✓: the page opens the place; its dot clears
9
+ * POST /api/moments/drop { ws, id } → { ok } ✕: that moment never comes back
10
+ * POST /api/moments/visit { place } → { ok } the PM opened Board, Intelligence or reports: its dot clears
11
+ *
12
+ * Reads files only (no model, no connector). A GET records a place that just opened in the workspace's onboarding-progress record, so its
13
+ * dot shows once. WHO WRITES: every POST is a page press (pm-guard requirePagePress); an agent session header is refused (403). */
14
+ const guard = require('./pm-guard.js');
15
+ const MO = require('./moments.js');
16
+
17
+ const text = (v) => (typeof v === 'string' ? v : '');
18
+ const bad = (status, error) => ({ status, body: { ok: false, error } });
19
+ const nowMs = (ctx) => { const t = Date.parse((ctx.env || process.env).PMOS_MOMENTS_NOW || ''); return Number.isFinite(t) ? t : Date.now(); };
20
+
21
+ async function handle(req, res, url, ctx) {
22
+ const send = (r) => { ctx.json(res, r.body, r.status); return true; };
23
+ const what = url.pathname.replace(/^\/api\/moments\/?/, '');
24
+ if (!ctx.runtimeDir) return send(bad(409, 'There is no runtime folder.'));
25
+ if (what === '') {
26
+ if (req.method !== 'GET') return send(bad(405, 'GET only'));
27
+ try { return send({ status: 200, body: { ok: true, ...MO.current(ctx, nowMs(ctx)) } }); }
28
+ catch { return send(bad(500, 'The moments could not be read.')); }
29
+ }
30
+ if (!['shown', 'keep', 'drop', 'visit'].includes(what)) return send(bad(404, 'no such moments route'));
31
+ if (req.method !== 'POST') return send(bad(405, 'POST only'));
32
+ if (req.headers && req.headers['x-pmos-session']) return send(bad(403, 'Only you answer a moment, from the page.'));
33
+ const rd = await ctx.readJson(req);
34
+ if (!rd.ok) return send(bad(400, 'the body is not JSON'));
35
+ const body = rd.body || {};
36
+ const g = guard.requirePagePress(req, body);
37
+ if (!g.ok) return send(bad(g.status, g.error));
38
+ const r = what === 'visit' ? MO.visit(ctx, text(body.place), nowMs(ctx)) : MO.answer(ctx, what, text(body.ws), text(body.id), nowMs(ctx));
39
+ return send(r.ok ? { status: 200, body: { ok: true } } : bad(r.status, r.error));
40
+ }
41
+
42
+ module.exports = { prefixes: ['/api/moments'], handle };
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+ /* routes-morning-goals.js — the quarter under Good morning (build brief Good morning, GM1). lib/morning-goals.js holds the rules.
3
+ *
4
+ * GET /api/morning-goals[?ws=a,b] → { ok, today, quarter, weeksLeft, strip, line, proposal, since, tags, goals, missing }
5
+ * ws: the products to read; with none, the products Home reads (GET /api/briefing-premium/products, the ticked ones).
6
+ * Each product's goals come from its Intelligence model (GET /api/insights-premium?ws=), so a goal says the same thing on both pages.
7
+ * line: { line, at } the PM kept for this quarter, or null. proposal: Claude's ✦ { line, of, why } when there is no line and the
8
+ * ✦ was not put off. since: the move since the day's first read (null on the first read). A product that cannot be read is named
9
+ * in missing and is not counted. The first read of the day writes <runtime>/goal-morning.jsonl once.
10
+ * POST /api/morning-goals/line { line, quarter? } the PM keeps the line (✓, the number may be changed). A page press
11
+ * POST /api/morning-goals/line-off { quarter? } the ✦ is put off for the quarter (✕). A page press
12
+ * POST /api/morning-goals/yes-effect { ws, goal, date } | { ws, initiative, date, from } → { ok, effect | null }
13
+ * the forecast run again with the proposed date (lib/forecast.js). Reads and writes nothing else. null when it cannot run. */
14
+ const guard = require('./pm-guard.js');
15
+ const MG = require('./morning-goals.js');
16
+
17
+ const localDay = (ms = Date.now()) => { const d = new Date(ms); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
18
+ const text = (v) => (typeof v === 'string' ? v : '');
19
+
20
+ async function via(mod, pathAndQuery, req, ctx) {
21
+ let out = { status: 500, body: null };
22
+ const c = Object.assign({}, ctx, { json: (res, obj, code) => { out = { status: code || 200, body: obj }; } });
23
+ const handled = await mod.handle({ method: 'GET', headers: (req && req.headers) || {} }, {}, new URL('http://local' + pathAndQuery), c);
24
+ return handled === false ? { status: 404, body: null } : out;
25
+ }
26
+
27
+ async function productsOf(ctx, req, want) {
28
+ if (want.length) return want.map((ws) => ({ ws, name: ws }));
29
+ const r = await via(require('./routes-briefing-premium.js'), '/api/briefing-premium/products', req, ctx).catch(() => ({ status: 500, body: null }));
30
+ const P = (r.body && Array.isArray(r.body.products) && r.body.products) || [];
31
+ return P.filter((p) => p && p.on).map((p) => ({ ws: p.ws, name: p.name || p.ws }));
32
+ }
33
+
34
+ /** One product's Intelligence model goals, or null when it cannot be read. */
35
+ async function modelOf(ctx, req, ws) {
36
+ const r = await via(require('./routes-insights-premium.js'), '/api/insights-premium?ws=' + encodeURIComponent(ws), req, ctx).catch(() => ({ status: 500, body: null }));
37
+ const m = r.status === 200 && r.body && r.body.ok && r.body.model;
38
+ return m && m.board && m.board.ws === ws ? m : null;
39
+ }
40
+
41
+ async function read(ctx, req, want) {
42
+ const today = localDay(), quarter = MG.quarterOf(today);
43
+ const products = [], missing = [];
44
+ for (const p of await productsOf(ctx, req, want)) {
45
+ const m = await modelOf(ctx, req, p.ws);
46
+ if (!m) { missing.push(p.name); continue; }
47
+ products.push({ ws: p.ws, name: (m.board && m.board.name && m.board.name !== 'Intelligence' ? m.board.name : p.name), goals: m.goals || [], model: m });
48
+ }
49
+ const list = MG.collect(products);
50
+ const dir = ctx.runtimeDir || null;
51
+ const kept = dir ? MG.lineFor(dir, quarter) : null;
52
+ const line = kept && kept.line ? kept : null;
53
+ const strip = MG.stripOf(list, line ? line.line : null);
54
+ const proposal = !line && !(kept && kept.off) ? MG.proposeLine(strip) : null;
55
+ let since = null;
56
+ if (dir && list.length) {
57
+ try {
58
+ const had = MG.snapshotFor(dir, today);
59
+ if (had) since = MG.deltaOf(had, list);
60
+ else if (!missing.length) MG.takeSnapshot(dir, today, list); // a part read is no morning: the snapshot waits for every product
61
+ } catch { since = null; }
62
+ }
63
+ return { ok: true, today, quarter, weeksLeft: MG.weeksLeft(today), strip, line, proposal, since, tags: MG.tagsOf(list),
64
+ goals: list.map(({ moves, ...g }) => g), missing, products };
65
+ }
66
+
67
+ module.exports = {
68
+ prefixes: ['/api/morning-goals'],
69
+ handle: async (req, res, url, ctx) => {
70
+ const send = (status, body) => { ctx.json(res, body, status); return true; };
71
+ const what = url.pathname.replace(/^\/api\/morning-goals\/?/, '');
72
+ if (!['', 'line', 'line-off', 'yes-effect'].includes(what)) return send(404, { ok: false, error: 'no such morning-goals route' });
73
+ if (what === '') {
74
+ if (req.method !== 'GET') return send(405, { ok: false, error: 'GET only' });
75
+ const want = text(url.searchParams.get('ws')).split(',').map((s) => s.trim()).filter(Boolean);
76
+ try { const out = await read(ctx, req, want); delete out.products; return send(200, out); } catch (e) { return send(500, { ok: false, error: String((e && e.message) || e) }); }
77
+ }
78
+ if (req.method !== 'POST') return send(405, { ok: false, error: 'POST only' });
79
+ const got = await ctx.readJson(req);
80
+ if (!got.ok) return send(400, { ok: false, error: 'the body is not JSON' });
81
+ const body = got.body || {};
82
+ if (what === 'yes-effect') {
83
+ const date = text(body.date), today = localDay();
84
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return send(400, { ok: false, error: 'date is required, as YYYY-MM-DD' });
85
+ const FC = require('./forecast.js');
86
+ let effect = null;
87
+ try {
88
+ if (body.goal) {
89
+ const m = await modelOf(ctx, req, text(body.ws));
90
+ const g = m && (m.goals || []).find((x) => String(x.id) === String(body.goal));
91
+ if (g) effect = MG.yesEffect({ goal: g, date }, FC, today);
92
+ } else if (body.initiative) {
93
+ const w = ctx.getWs(text(body.ws) || null), sn = w ? require('./jira-status.js').readSnapshot(w.root, String(body.initiative)) : null;
94
+ const jira = sn ? { at: sn.fetchedAt, tickets: [...sn.tickets.values()].map((t) => ({ category: t.category, resolved: t.resolved || null })) } : null;
95
+ effect = MG.yesEffect({ initiative: { id: String(body.initiative), title: text(body.title) }, jira, date, from: text(body.from) }, FC, today);
96
+ }
97
+ } catch { effect = null; }
98
+ return send(200, { ok: true, effect });
99
+ }
100
+ const g = guard.requirePagePress(req, body);
101
+ if (!g.ok) return send(g.status, { ok: false, error: g.error });
102
+ if (!ctx.runtimeDir) return send(409, { ok: false, error: 'PMOS has no runtime folder, so the line cannot be kept' });
103
+ const quarter = text(body.quarter) || MG.quarterOf(localDay());
104
+ const r = what === 'line' ? MG.setLine(ctx.runtimeDir, g.actor, { quarter, line: body.line }) : MG.putOff(ctx.runtimeDir, g.actor, quarter);
105
+ if (r.error) return send(400, { ok: false, error: r.error });
106
+ return send(200, { ok: true, line: r.line });
107
+ },
108
+ _read: read,
109
+ };