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.
- package/bin/create-pmos.js +26 -1
- package/package.json +57 -47
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/changelog.json +56 -0
- package/workspace/guide/agent-capabilities.md +6 -3
- package/workspace/guide/home.md +1 -1
- package/workspace/guide/horizon-scan.md +2 -2
- package/workspace/guide/idle-lab.md +46 -0
- package/workspace/guide/index.md +2 -0
- package/workspace/guide/moments.md +43 -0
- package/workspace/guide/welcome.md +37 -28
- package/workspace/lib/bg-queue.js +226 -0
- package/workspace/lib/connector-read.js +19 -0
- package/workspace/lib/driver-run.js +8 -1
- package/workspace/lib/first-reveal.js +311 -0
- package/workspace/lib/headless.js +2 -2
- package/workspace/lib/idle-lab.js +275 -0
- package/workspace/lib/moments.js +241 -0
- package/workspace/lib/morning-goals.js +191 -0
- package/workspace/lib/onboard-fundamentals.js +205 -0
- package/workspace/lib/onboarding-progress.js +13 -1
- package/workspace/lib/orchestrator-spawn.js +9 -2
- package/workspace/lib/project-onboard.js +93 -22
- package/workspace/lib/proposals.js +9 -5
- package/workspace/lib/refresh-run.js +2 -1
- package/workspace/lib/route-modules.js +4 -0
- package/workspace/lib/routes-lab.js +137 -0
- package/workspace/lib/routes-loose.js +1 -0
- package/workspace/lib/routes-moments.js +42 -0
- package/workspace/lib/routes-morning-goals.js +109 -0
- package/workspace/lib/routes-onboard.js +45 -8
- package/workspace/lib/routes-proposals.js +12 -2
- package/workspace/lib/routes-reveal.js +73 -0
- package/workspace/lib/scan-run.js +4 -3
- package/workspace/lib/scan.js +5 -3
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/project-onboard-flow-test.js +70 -57
- package/workspace/public/pm/cozy-ev.css +3 -0
- package/workspace/public/pm/cozy-lab-ins.js +87 -0
- package/workspace/public/pm/cozy-lab.css +86 -0
- package/workspace/public/pm/cozy-lab.js +138 -0
- package/workspace/public/pm/cozy-nav.js +22 -8
- package/workspace/public/pm/cozy-onboard.css +31 -0
- package/workspace/public/pm/cozy-onboard.js +217 -111
- package/workspace/public/pm/cozy-reveal.css +52 -0
- package/workspace/public/pm/cozy-reveal.js +130 -0
- package/workspace/public/pm/home-app.js +38 -3
- package/workspace/public/pm/home-impact.css +12 -0
- package/workspace/public/pm/home-moment.js +41 -0
- package/workspace/public/pm/ini-props.js +15 -6
- package/workspace/public/pm/premium-board.js +2 -0
- package/workspace/public/pm/premium-first.js +1 -1
- package/workspace/public/pm/premium-insights.js +6 -1
- package/workspace/public/pm/quiet-place.js +31 -0
- package/workspace/public/premium-intelligence.html +3 -0
- package/workspace/replaced-tests.json +14 -0
- package/workspace/server.js +46 -5
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/first-reveal.json +9 -0
- package/workspace/test-lists/idle-lab.json +9 -0
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|