create-pmos 0.523.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/package.json +44 -16
- 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 +43 -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/driver-run.js +8 -1
- package/workspace/lib/first-reveal.js +311 -0
- 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 +86 -6
- package/workspace/lib/proposals.js +9 -5
- 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 +28 -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
|
@@ -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
|
}
|
|
@@ -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
|
+
};
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* POST /api/onboard/discover the verified profile's connectors, no model call
|
|
15
15
|
* POST /api/onboard/search { sources: [id], local: bool } read-only search of the ticked sources
|
|
16
16
|
* POST /api/onboard/review { fields, jira } the PM's edits to the draft
|
|
17
|
-
* POST /api/onboard/init initialize or reuse, register,
|
|
17
|
+
* POST /api/onboard/init initialize or reuse, register, settle the fundamentals (resumable)
|
|
18
|
+
* POST /api/onboard/fundamental { key } the one-press change of one fundamentals line (lib/onboard-fundamentals.js)
|
|
18
19
|
* POST /api/onboard/reset start again (the folder and anything written stay)
|
|
19
20
|
*/
|
|
20
21
|
const fs = require('fs');
|
|
@@ -22,6 +23,7 @@ const path = require('path');
|
|
|
22
23
|
const crypto = require('crypto');
|
|
23
24
|
const guard = require('./pm-guard.js');
|
|
24
25
|
const P = require('./project-onboard.js');
|
|
26
|
+
const F = require('./onboard-fundamentals.js');
|
|
25
27
|
|
|
26
28
|
function file(ctx) { return path.join(ctx.runtimeDir, 'onboarding', 'project.json'); }
|
|
27
29
|
function journal(ctx) { return path.join(ctx.runtimeDir, 'onboarding', 'project-init.json'); }
|
|
@@ -73,7 +75,8 @@ function installerKit(ctx) {
|
|
|
73
75
|
function view(ctx) {
|
|
74
76
|
const { st, bound } = fresh(ctx, read(ctx));
|
|
75
77
|
const ws = st.done && st.done.ws && ctx.getWs(st.done.ws);
|
|
76
|
-
|
|
78
|
+
const fundamentals = st.fundamentals ? { at: st.fundamentals.at, lines: st.fundamentals.lines, error: st.fundamentals.error || null } : null;
|
|
79
|
+
return { ok: true, progress: { ...st, verified: !!bound, done: ws ? st.done : null, fundamentals }, claude: ctx.claudeSetup.list(), platform: process.platform,
|
|
77
80
|
// a project the PM already has: a registered workspace with PMOS in it, or with the planning/ folder a PMOS workspace keeps its records in
|
|
78
81
|
// ...that holds work: an initiative or a filled record file. A PMOS folder with none is not one: the one-line installer makes ~/PMOS with an empty kit
|
|
79
82
|
// and registers it, so counting the bare folder skipped setup for every new user (bug 2026-11-02, Windows).
|
|
@@ -81,7 +84,7 @@ function view(ctx) {
|
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
async function handle(req, res, url, ctx) {
|
|
84
|
-
const m = /^\/api\/onboard\/(state|own|login|check|project|folder|discover|search|review|init|reset)$/.exec(url.pathname);
|
|
87
|
+
const m = /^\/api\/onboard\/(state|own|login|check|project|folder|discover|search|review|init|fundamental|reset)$/.exec(url.pathname);
|
|
85
88
|
if (!m) return false;
|
|
86
89
|
const send = (body, code = 200) => { ctx.json(res, body, code); return true; };
|
|
87
90
|
if (!ctx.claudeSetup) return send({ ok: false, error: 'Claude setup is unavailable.' }, 503);
|
|
@@ -167,7 +170,8 @@ async function handle(req, res, url, ctx) {
|
|
|
167
170
|
const picked = all.filter((s) => Array.isArray(b.sources) && b.sources.includes(s.id) && s.status === 'connected');
|
|
168
171
|
const local = b.local !== false && st.folder && st.folder.mode === 'existing';
|
|
169
172
|
if (!picked.length && !local) return send({ ok: false, error: 'Tick at least one source, or continue with your own description.' }, 400);
|
|
170
|
-
let found = { ok: true, sources: [], findings: [], jira: [], fields: {} };
|
|
173
|
+
let found = { ok: true, sources: [], findings: [], jira: [], fields: {}, highlights: [], goal: null, facts: {} };
|
|
174
|
+
const startedAt = new Date().toISOString(); // the reveal's ticks count from here (lib/first-reveal.js)
|
|
171
175
|
if (picked.length) {
|
|
172
176
|
const inv = await P.inventory({ bound, sources: picked });
|
|
173
177
|
found = inv.ok ? await P.search({ bound, project: st.project, sources: picked, tools: inv.tools }) : { ok: false, error: inv.error };
|
|
@@ -177,12 +181,12 @@ async function handle(req, res, url, ctx) {
|
|
|
177
181
|
if (local) found.sources.push({ id: 'folder', label: 'The folder', state: folder && folder.ok ? (folder.readme || folder.commits ? 'found' : 'empty') : 'failed', count: folder && folder.commits || 0 });
|
|
178
182
|
const jiraMatches = found.jira.length ? found.jira : [];
|
|
179
183
|
const draft = P.draftContext({ project: st.project, folder: folder || (st.folder && st.folder.facts), found });
|
|
180
|
-
write(ctx, { ...st, found: { fingerprint: bound.fingerprint, at: new Date().toISOString(), sources: found.sources, findings: found.findings, jira: jiraMatches }, review: { fields: draft, jira: jiraMatches.length === 1 ? jiraMatches[0].key : null } });
|
|
184
|
+
write(ctx, { ...st, found: { fingerprint: bound.fingerprint, started_at: startedAt, at: new Date().toISOString(), sources: found.sources, findings: found.findings, jira: jiraMatches, highlights: found.highlights || [], goal: found.goal || null, facts: found.facts || {} }, review: { fields: draft, jira: jiraMatches.length === 1 ? jiraMatches[0].key : null } });
|
|
181
185
|
return send(view(ctx));
|
|
182
186
|
}
|
|
183
187
|
if (verb === 'review') {
|
|
184
188
|
const fields = { ...((st.review && st.review.fields) || P.draftContext({ project: st.project, folder: st.folder && st.folder.facts, found: null })) };
|
|
185
|
-
for (const k of ['name', 'purpose', 'team', 'owner', 'timeline', 'goals']) {
|
|
189
|
+
for (const k of ['name', 'purpose', 'team', 'owner', 'timeline', 'weekly', 'goals']) {
|
|
186
190
|
if (!(b.fields && k in b.fields)) continue;
|
|
187
191
|
const v = str(b.fields[k], 400);
|
|
188
192
|
fields[k] = v ? (fields[k] && fields[k].value === v ? fields[k] : { value: v, source: 'you' }) : null;
|
|
@@ -201,15 +205,31 @@ async function handle(req, res, url, ctx) {
|
|
|
201
205
|
const fields = (st.review && st.review.fields) || P.draftContext({ project: st.project, folder: st.folder.facts, found: null });
|
|
202
206
|
const jira = jiraKey && jiraKey !== 'none' ? (matches.find((j) => j.key === jiraKey) || null) : null;
|
|
203
207
|
const name = (fields.name && fields.name.value) || st.project.name;
|
|
204
|
-
const
|
|
208
|
+
const fg = st.found && st.found.goal, gf = fg && (st.found.findings || []).find((x) => x.id === fg.finding);
|
|
209
|
+
const goal = fg ? { objective: fg.objective, kr: fg.kr, source_label: gf ? gf.source_label : null } : null;
|
|
210
|
+
const plan = { mode: st.folder.mode, root: st.folder.root, name, context: { name, fields, jira, goal, sources: (st.found && st.found.findings || []).slice(0, 20) } };
|
|
205
211
|
const r = P.initialize({ plan, journalFile: journal(ctx) });
|
|
206
212
|
if (!r.ok) { write(ctx, { ...st, init: { ok: false, step: r.step, error: r.error, steps: r.journal && r.journal.steps } }); return send({ ...view(ctx), ok: false, error: r.error, step: r.step }); }
|
|
207
213
|
const w = ctx.registerWorkspace(r.root, name);
|
|
208
214
|
if (!w) { write(ctx, { ...st, init: { ok: false, step: 'register', error: 'The project could not be added to My Work.', steps: r.journal.steps } }); return send({ ...view(ctx), ok: false, error: 'The project could not be added to My Work.' }); }
|
|
215
|
+
// A3: the fundamentals are settled for the PM right after init, read from the project-context.md just written. Once per product: a
|
|
216
|
+
// retried init keeps the card it already has, so a change the PM pressed is not undone.
|
|
217
|
+
let fundamentals = st.fundamentals && st.fundamentals.root === r.root ? st.fundamentals : null;
|
|
218
|
+
if (!fundamentals) {
|
|
219
|
+
try { fundamentals = F.settle({ root: r.root, ws: w.id, sources: (st.found && st.found.sources) || [], fresh: !!(r.journal.steps.kit && r.journal.steps.kit.detail === 'installed'), refresh: ctx.refreshSources ? ctx.refreshSources() : F.refreshAt(ctx.runtimeDir), ...(ctx.launcher !== undefined ? { launcher: ctx.launcher } : {}) }); }
|
|
220
|
+
catch { fundamentals = { root: r.root, ws: w.id, lines: [], did: {}, error: 'Some fundamentals could not be settled. Change them in Settings.' }; }
|
|
221
|
+
}
|
|
209
222
|
if (ctx.broadcast) ctx.broadcast({ t: 'focus', ws: w.id });
|
|
210
|
-
write(ctx, { ...st, init: { ok: true, steps: r.journal.steps }, done: { ws: w.id, name, root: r.root, context: r.journal.steps.context && r.journal.steps.context.detail, at: new Date().toISOString() } });
|
|
223
|
+
write(ctx, { ...st, init: { ok: true, steps: r.journal.steps }, fundamentals, done: { ws: w.id, name, root: r.root, context: r.journal.steps.context && r.journal.steps.context.detail, at: new Date().toISOString() } });
|
|
211
224
|
return send(view(ctx));
|
|
212
225
|
}
|
|
226
|
+
if (verb === 'fundamental') {
|
|
227
|
+
if (!st.fundamentals) return send({ ok: false, error: 'Set up the project first.', need: 'init' }, 409);
|
|
228
|
+
const r = F.change(st.fundamentals, str(b.key, 20), { refresh: ctx.refreshSources ? ctx.refreshSources() : F.refreshAt(ctx.runtimeDir), ...(ctx.launcher !== undefined ? { launcher: ctx.launcher } : {}) });
|
|
229
|
+
if (!r.ok) return send({ ...view(ctx), ok: false, error: r.error });
|
|
230
|
+
if (r.state !== st.fundamentals) write(ctx, { ...st, fundamentals: r.state });
|
|
231
|
+
return send({ ...view(ctx), follow: r.follow || null });
|
|
232
|
+
}
|
|
213
233
|
} catch { return send({ ok: false, error: 'This step could not finish. Nothing you had was removed. Try again.' }, 500); }
|
|
214
234
|
return false;
|
|
215
235
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* POST /api/proposals/keep { ws, board, initiative, ids?: [id] | all: true, choices?: { <from>><to>: 'keep' | 'ignore' } }
|
|
9
9
|
* → { ok, batch, said, counts, props } ✓ one proposal, or ✓ in the header (all: every one Claude is sure of;
|
|
10
10
|
* Check these and a Jira ticket's comment never ride on it). One batch, one toast, one Undo.
|
|
11
|
-
* POST /api/proposals/drop { ws, board, initiative, sig } → { ok } ✕ on a proposal or on one item of it, for good (rule 7); the line
|
|
11
|
+
* POST /api/proposals/drop { ws, board, initiative, sig, why? } → { ok } why: the PM's reason (the idle lab asks for one); ✕ on a proposal or on one item of it, for good (rule 7); the line
|
|
12
12
|
* also keeps its facts (PR.factsOf), so a re-sectioned task or a new day does not bring it back
|
|
13
13
|
* POST /api/proposals/undo { ws, board, batch } → { ok, back, kept } the whole batch; a task changed since keeps its change
|
|
14
14
|
* POST /api/proposals/stop { ws, board, initiative } → { ok } ■ Stop: the look ends; what was found so far stays proposed
|
|
@@ -142,6 +142,14 @@ function applyOne(ctx, p, g, f, ini, x, choices, outbox) {
|
|
|
142
142
|
// the record's own description (initiative-description-field): ✓ writes its description line, Undo puts back what was there
|
|
143
143
|
const r = require('./initiative-writer.js').setDescription(p.w.root, ini, (x.data || {}).text, { actor: g.actor });
|
|
144
144
|
if (r.ok && !r.same) add({ op: 'description', from: r.from, to: r.text });
|
|
145
|
+
} else if (x.group === 'prototyped' || x.group === 'improved' || x.group === 'scanned') {
|
|
146
|
+
// the idle lab (lib/idle-lab.js): ✓ keeps it as the initiative's own document. A prototype is each specimen of round 1, or the new
|
|
147
|
+
// round; a scan finding is evidence. The specimen stays in the runtime folder; the document links it (GET /api/lab/specimen)
|
|
148
|
+
const LAB = require('./idle-lab.js'), d = x.data || {}, name = (sp) => 'Prototype ' + sp.key + ' · ' + sp.title;
|
|
149
|
+
const docOf = (title, ref, kind) => add({ op: 'doc', id: 'fd-' + PR.hash(ini + '|' + x.id + '|' + (ref || title)), title: String(title).slice(0, 140), source: 'Claude, while you were away', at: x.at || '', ref, kind, claude: true });
|
|
150
|
+
if (x.group === 'prototyped') for (const sp of d.specimens || []) docOf(name(sp), LAB.specimenUrl(sp.file), 'prototype');
|
|
151
|
+
else if (x.group === 'improved') docOf('Prototype ' + (d.base || '') + ' · round ' + d.round + ': ' + (d.changed || ''), LAB.specimenUrl(d.file), 'prototype');
|
|
152
|
+
else docOf(x.why, ((x.sources || [])[0] || {}).ref || '', 'evidence');
|
|
145
153
|
} else if (x.group === 'grill') {
|
|
146
154
|
// a grill finding (lib/grill.js): ✓ adds its next step as a task, with the finding and its sources in the notes
|
|
147
155
|
// ✎ on the finding: the PM's own words for the next step (choices[id]) replace Claude's
|
|
@@ -279,7 +287,9 @@ const WRITES = {
|
|
|
279
287
|
if (it) { facts = PR.factsOf(x, it); break; }
|
|
280
288
|
}
|
|
281
289
|
} catch { facts = []; }
|
|
282
|
-
|
|
290
|
+
// the PM's reason, when given (the idle lab's ✕ asks for one; Claude learns from it)
|
|
291
|
+
const why = typeof body.why === 'string' ? body.why.replace(/\s+/g, ' ').trim().slice(0, 300) : '';
|
|
292
|
+
PR.append(dirOf(ctx, p.w), g.actor, { kind: 'drop', ini, sig, ...(facts.length ? { facts } : {}), ...(why ? { why } : {}) });
|
|
283
293
|
ctx.json(res, { ok: true });
|
|
284
294
|
},
|
|
285
295
|
'/api/proposals/undo': (ctx, res, body, g, p) => {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-reveal.js — Home right after setup (onboarding build B2; lib/first-reveal.js has the model and the one write).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/first-reveal?ws=<id> → the reveal of a product just set up, or { none: true }
|
|
5
|
+
* POST /api/first-reveal/keep { ws, outcome? } → ✓ on the drafted initiative: it is created with its expected outcome and check date
|
|
6
|
+
* POST /api/first-reveal/drop { ws } → ✕: nothing is written; the draft does not come back for this product
|
|
7
|
+
*
|
|
8
|
+
* It reads the onboarding progress of lib/routes-onboard.js (the per-user runtime folder) and nothing from a connector: what the portrait
|
|
9
|
+
* shows is what the read-only search returned during setup. Every POST is the PM's own page press (pm-guard requirePagePress).
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const guard = require('./pm-guard.js');
|
|
15
|
+
const R = require('./first-reveal.js');
|
|
16
|
+
|
|
17
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
18
|
+
const localDay = (d = new Date()) => d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
|
19
|
+
const readJsonFile = (f) => { try { const v = JSON.parse(fs.readFileSync(f, 'utf8')); return v && typeof v === 'object' ? v : null; } catch { return null; } };
|
|
20
|
+
const progressOf = (ctx) => readJsonFile(path.join(ctx.runtimeDir, 'onboarding', 'project.json')) || {};
|
|
21
|
+
const journalOf = (ctx) => readJsonFile(path.join(ctx.runtimeDir, 'onboarding', 'project-init.json'));
|
|
22
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/;
|
|
23
|
+
|
|
24
|
+
/** The default workflow's name for new initiatives of this product (D99; Product feature when none is set). */
|
|
25
|
+
function workflowName(ctx, w) {
|
|
26
|
+
try {
|
|
27
|
+
const WS = require('./workflows-store.js');
|
|
28
|
+
const WF = require(require('./repo-script.js').repoScript('workflows.js'));
|
|
29
|
+
const dir = ctx.stateDirFor ? ctx.stateDirFor(w) : null;
|
|
30
|
+
return (dir && WF.defaultOf(WS.library(dir)).name) || 'Product feature';
|
|
31
|
+
} catch { return 'Product feature'; }
|
|
32
|
+
}
|
|
33
|
+
function hasKr(w) { return !!R.firstKr(w.root); }
|
|
34
|
+
|
|
35
|
+
async function handle(req, res, url, ctx) {
|
|
36
|
+
const m = /^\/api\/first-reveal(?:\/(keep|drop))?$/.exec(url.pathname);
|
|
37
|
+
if (!m) return false;
|
|
38
|
+
const send = (body, code = 200) => { ctx.json(res, body, code); return true; };
|
|
39
|
+
if (!ctx.runtimeDir) return send({ ok: false, error: 'PMOS has no runtime folder.' }, 409);
|
|
40
|
+
if (!m[1]) {
|
|
41
|
+
if (req.method !== 'GET') return send({ ok: false, error: 'GET only' }, 405);
|
|
42
|
+
const ws = url.searchParams.get('ws') || '';
|
|
43
|
+
const st = progressOf(ctx);
|
|
44
|
+
if (!ws || !st.done || st.done.ws !== ws) return send({ ok: true, none: true });
|
|
45
|
+
const w = ctx.getWs(ws);
|
|
46
|
+
if (!w) return send({ ok: true, none: true });
|
|
47
|
+
const today = /^\d{4}-\d{2}-\d{2}$/.test(url.searchParams.get('today') || '') ? url.searchParams.get('today') : localDay();
|
|
48
|
+
return send(R.model({ st, journal: journalOf(ctx), ws, today, workflow: workflowName(ctx, w), decided: R.read(ctx.runtimeDir, ws), home: os.homedir(), hasKr: hasKr(w) }));
|
|
49
|
+
}
|
|
50
|
+
if (req.method !== 'POST') return send({ ok: false, error: 'POST only' }, 405);
|
|
51
|
+
const got = await ctx.readJson(req);
|
|
52
|
+
if (!got.ok || !got.body || typeof got.body !== 'object') return send({ ok: false, error: 'Invalid JSON.' }, 400);
|
|
53
|
+
const b = got.body, g = guard.requirePagePress(req, b);
|
|
54
|
+
if (!g.ok) return send({ ok: false, error: g.error }, g.status);
|
|
55
|
+
const ws = String(b.ws || '');
|
|
56
|
+
if (!ID_RE.test(ws)) return send({ ok: false, error: 'Name the product.' }, 400);
|
|
57
|
+
const st = progressOf(ctx);
|
|
58
|
+
if (!st.done || st.done.ws !== ws) return send({ ok: false, error: 'This product was not set up here.' }, 409);
|
|
59
|
+
const today = /^\d{4}-\d{2}-\d{2}$/.test(String(b.today || '')) ? String(b.today) : localDay();
|
|
60
|
+
try {
|
|
61
|
+
if (m[1] === 'drop') {
|
|
62
|
+
const d = R.read(ctx.runtimeDir, ws);
|
|
63
|
+
if (!d.kept && !d.dropped) R.write(ctx.runtimeDir, ws, { dropped: new Date().toISOString() });
|
|
64
|
+
return send({ ok: true, dropped: true });
|
|
65
|
+
}
|
|
66
|
+
const r = R.keep(ctx, g, { ws, outcome: typeof b.outcome === 'string' ? b.outcome : '', today, st });
|
|
67
|
+
if (!r.ok) return send({ ok: false, error: r.error, field: r.field || null }, r.status || 400);
|
|
68
|
+
if (ctx.invalidateNeedsAgg) ctx.invalidateNeedsAgg(ws);
|
|
69
|
+
return send(r);
|
|
70
|
+
} catch { return send({ ok: false, error: 'This did not finish. Nothing was removed. Try again.' }, 500); }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { prefixes: ['/api/first-reveal'], handle };
|