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
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* idle-lab.js — what the PM sees of the idle lab (build U of the idle-lab B+C direction, 2026-11-04; canvas main, In development I1 IlBC,
|
|
3
|
+
* I2 IlB, I3 IlCompare; brief /mnt/project-files/idle-lab/brief.md). The engine (build E) runs the jobs; this file is what they file and
|
|
4
|
+
* what the screens read. Nothing here runs a model.
|
|
5
|
+
*
|
|
6
|
+
* TWO PLACES A RESULT WAITS (B + C):
|
|
7
|
+
* on its initiative a ✦ under the initiative's one blue ✦ (lib/proposals.js), one of three groups, always section 'check' (its own ✓):
|
|
8
|
+
* prototyped "Claude prototyped it": round 1, 2–3 divergent specimens. data { round: 1, specimens: [{ key, title, caption, file }], checked }
|
|
9
|
+
* improved "Claude improved it": a new round of one specimen. data { round, of: <the prototyped proposal id>, base: <specimen key>,
|
|
10
|
+
* changed: what changed (usually one thing), file, checked }
|
|
11
|
+
* scanned "Claude scanned for it": a finding to attach as evidence. why is the finding; sources are the pages read.
|
|
12
|
+
* data { pages: n, refreshed?: 'from the 18 Oct scan, 2 things new since' }
|
|
13
|
+
* ✓ keeps it as the initiative's own document (a `doc` write, kind prototype or evidence: lib/routes-proposals.js). ✕ drops it (an
|
|
14
|
+
* optional reason rides on the drop line, for the engine to learn from). Undo gives it back. Every specimen is a file under
|
|
15
|
+
* <runtime>/idle-lab/specimens/; nothing is written to a product repo before ✓.
|
|
16
|
+
* in Intelligence › Lab only the ✦ that fit no initiative, in <state>/idle-lab.jsonl (THE ONE WRITER and THE ONE FOLD of that file):
|
|
17
|
+
* start start an initiative from a scan finding. data { title, board, anchor, stage? } ✓ starts it through the Board (Add an ask, Accept)
|
|
18
|
+
* scan a finding worth knowing that no initiative holds yet. ✓ keeps it (writes nothing else)
|
|
19
|
+
*
|
|
20
|
+
* THE LAB LEDGER, <state>/idle-lab.jsonl, one line per event { at, by, kind, ... }:
|
|
21
|
+
* propose { id, type: 'start' | 'scan', title, why, sources: [{ label, ref }], data } the last line for an id wins
|
|
22
|
+
* drop { id, batch, why? } ✕, with the PM's reason when given; an undo of its batch gives it back
|
|
23
|
+
* keep { id, batch, wrote } ✓, with what it wrote (op create: the initiative started)
|
|
24
|
+
* undo { batch }
|
|
25
|
+
* run { job, ms?, n? } one idle stretch the engine used: its job, the idle time it took and how many results it filed
|
|
26
|
+
* next { items: [{ kind, title, why }] } what the engine will do the next time the PM steps away (the newest line wins; read only)
|
|
27
|
+
*
|
|
28
|
+
* PROVEN (proven-only rule, Wawan 2026-11-01): the compare view's Production and Design columns stay left out, with one line why, until the
|
|
29
|
+
* engine has shown on the PM's Mac that it can render production from the product repo and read the design (Figma or a canvas). The engine
|
|
30
|
+
* flips these when that is proven; until then no pin against them is drawn.
|
|
31
|
+
*
|
|
32
|
+
* PROVEN · GROUPS · TITLES · specimenDir(runtimeDir) · specimenPath(runtimeDir, file) → absolute path | null (never outside the folder)
|
|
33
|
+
* result(group, input) → { prop } | { error } what the engine files on an initiative (lib/proposals.js propose line)
|
|
34
|
+
* loose(type, input) → { prop } | { error } what the engine files in the Lab
|
|
35
|
+
* ledgerFile(dir) · readLines(file) · read(dir) → fold · append(dir, actor, line)
|
|
36
|
+
* fold(lines) PURE → { props, drops, kept, undone, runs, next, t }
|
|
37
|
+
* looseOpen(F) PURE → the open Lab ✦, newest first
|
|
38
|
+
* labOf({ P, L, extra, now, titleOf }) PURE → the Lab section: { say, loose, record, kept, next } or null (zero is silence)
|
|
39
|
+
* compareOf(P, id, { key, titleOf, proven }) PURE → the compare view: { title, round, say, columns, pins, history } | null */
|
|
40
|
+
const fs = require('fs');
|
|
41
|
+
const path = require('path');
|
|
42
|
+
const crypto = require('crypto');
|
|
43
|
+
|
|
44
|
+
const LEDGER = 'idle-lab.jsonl';
|
|
45
|
+
const KINDS = new Set(['propose', 'drop', 'keep', 'undo', 'run', 'next']);
|
|
46
|
+
const GROUPS = Object.freeze(['prototyped', 'improved', 'scanned']);
|
|
47
|
+
const TITLES = Object.freeze({ prototyped: 'Claude prototyped it', improved: 'Claude improved it', scanned: 'Claude scanned for it' });
|
|
48
|
+
const LOOSE = Object.freeze(['start', 'scan']);
|
|
49
|
+
const PROVEN = Object.freeze({ production: false, design: false });
|
|
50
|
+
const WEEK = 7 * 864e5;
|
|
51
|
+
const LIM = { title: 140, why: 300, label: 80, ref: 500, caption: 120, file: 200, changed: 200, checked: 200 };
|
|
52
|
+
const FILE_RE = /^[A-Za-z0-9][\w.-]*(\/[A-Za-z0-9][\w.-]*)*\.html$/;
|
|
53
|
+
const KEY_RE = /^[A-Z]$/;
|
|
54
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/;
|
|
55
|
+
|
|
56
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
57
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
58
|
+
const one = (v, n) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, n);
|
|
59
|
+
const hash = (s) => crypto.createHash('sha1').update(String(s)).digest('hex').slice(0, 10);
|
|
60
|
+
const plural = (n, a, b) => n + ' ' + (n === 1 ? a : b);
|
|
61
|
+
|
|
62
|
+
/* ── the specimens ── */
|
|
63
|
+
const specimenDir = (rt) => (rt ? path.join(rt, 'idle-lab', 'specimens') : null);
|
|
64
|
+
/** The absolute path of a specimen file, or null when the name is not a plain relative .html name inside the folder. */
|
|
65
|
+
function specimenPath(rt, file) {
|
|
66
|
+
const d = specimenDir(rt), f = str(file);
|
|
67
|
+
if (!d || !FILE_RE.test(f) || f.split('/').some((s) => s === '..' || s === '.')) return null;
|
|
68
|
+
const abs = path.resolve(d, f);
|
|
69
|
+
return abs.startsWith(path.resolve(d) + path.sep) ? abs : null;
|
|
70
|
+
}
|
|
71
|
+
const specimenUrl = (file) => '/api/lab/specimen?file=' + encodeURIComponent(file);
|
|
72
|
+
|
|
73
|
+
/* ── what the engine files ── */
|
|
74
|
+
function srcList(v, min) {
|
|
75
|
+
if (!Array.isArray(v) || v.length < min || v.length > 8) return { error: `sources is a list of ${min} to 8 { label, ref }: a claim without a pointer is not a result` };
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const s of v) {
|
|
78
|
+
if (!isObj(s) || !str(s.label).trim()) return { error: 'each source has a label (and a ref: a link, a path or a meeting note)' };
|
|
79
|
+
out.push({ label: one(s.label, LIM.label), ...(str(s.ref) ? { ref: one(s.ref, LIM.ref) } : {}) });
|
|
80
|
+
}
|
|
81
|
+
return { v: out };
|
|
82
|
+
}
|
|
83
|
+
/** → { prop } | { error }. The engine has already proven it (a specimen opens and renders; a scan cites the pages it read). */
|
|
84
|
+
function result(group, input) {
|
|
85
|
+
if (!GROUPS.includes(group)) return { error: 'group is one of ' + GROUPS.join(', ') };
|
|
86
|
+
if (!isObj(input)) return { error: 'the result is a JSON object' };
|
|
87
|
+
const why = one(input.why, LIM.why);
|
|
88
|
+
if (!why) return { error: 'why is one line: what Claude did and from what' };
|
|
89
|
+
const s = srcList(input.sources, 1); if (s.error) return s;
|
|
90
|
+
const data = {};
|
|
91
|
+
if (group === 'prototyped') {
|
|
92
|
+
const sp = Array.isArray(input.specimens) ? input.specimens : [];
|
|
93
|
+
if (sp.length < 2 || sp.length > 3) return { error: 'a first round has 2 or 3 different specimens' };
|
|
94
|
+
data.round = 1;
|
|
95
|
+
data.specimens = [];
|
|
96
|
+
for (const x of sp) {
|
|
97
|
+
if (!isObj(x) || !KEY_RE.test(str(x.key)) || !one(x.title, LIM.title) || !FILE_RE.test(str(x.file))) return { error: 'each specimen is { key: A–Z, title, caption, file: a .html name in the specimen folder }' };
|
|
98
|
+
data.specimens.push({ key: x.key, title: one(x.title, LIM.title), caption: one(x.caption, LIM.caption), file: x.file });
|
|
99
|
+
}
|
|
100
|
+
if (new Set(data.specimens.map((x) => x.key)).size !== data.specimens.length) return { error: 'each specimen has its own key' };
|
|
101
|
+
} else if (group === 'improved') {
|
|
102
|
+
if (!ID_RE.test(str(input.of))) return { error: 'of is the id of the prototyped proposal this round improves' };
|
|
103
|
+
if (!KEY_RE.test(str(input.base))) return { error: 'base is the key of the specimen this round improves' };
|
|
104
|
+
const round = Number(input.round);
|
|
105
|
+
if (!Number.isInteger(round) || round < 2 || round > 20) return { error: 'round is 2 or more' };
|
|
106
|
+
const changed = one(input.changed, LIM.changed);
|
|
107
|
+
if (!changed) return { error: 'changed says what changed since the last round, usually one thing' };
|
|
108
|
+
if (!FILE_RE.test(str(input.file))) return { error: 'file is a .html name in the specimen folder' };
|
|
109
|
+
Object.assign(data, { round, of: input.of, base: input.base, changed, file: input.file });
|
|
110
|
+
} else {
|
|
111
|
+
const pages = Number(input.pages);
|
|
112
|
+
data.pages = Number.isInteger(pages) && pages > 0 ? pages : s.v.length;
|
|
113
|
+
if (str(input.refreshed)) data.refreshed = one(input.refreshed, LIM.checked);
|
|
114
|
+
}
|
|
115
|
+
if (group !== 'scanned') {
|
|
116
|
+
const checked = one(input.checked, LIM.checked);
|
|
117
|
+
if (!checked) return { error: 'checked says how Claude proved it (for example: all three open and render in the Viewer)' };
|
|
118
|
+
data.checked = checked;
|
|
119
|
+
}
|
|
120
|
+
if (isObj(input.compare)) data.compare = compareData(input.compare);
|
|
121
|
+
return { prop: { group, type: group === 'improved' ? 'round' : group === 'prototyped' ? 'new' : 'finding', section: 'check', title: one(input.title, LIM.title) || null, why, sources: s.v, items: [], data } };
|
|
122
|
+
}
|
|
123
|
+
/** What the engine read for the compare view. Kept as filed; compareOf decides what shows. */
|
|
124
|
+
function compareData(c) {
|
|
125
|
+
const col = (x) => (isObj(x) && FILE_RE.test(str(x.file)) ? { file: x.file, read: one(x.read, LIM.checked) } : null);
|
|
126
|
+
const pins = (Array.isArray(c.pins) ? c.pins : []).filter((p) => isObj(p) && ['production', 'design'].includes(p.vs) && one(p.text, LIM.why))
|
|
127
|
+
.slice(0, 6).map((p) => ({ vs: p.vs, text: one(p.text, LIM.why) }));
|
|
128
|
+
return { production: col(c.production), design: col(c.design), pins };
|
|
129
|
+
}
|
|
130
|
+
/** → { prop } | { error }. A Lab ✦ that fits no initiative. */
|
|
131
|
+
function loose(type, input) {
|
|
132
|
+
if (!LOOSE.includes(type)) return { error: 'type is one of ' + LOOSE.join(', ') };
|
|
133
|
+
if (!isObj(input)) return { error: 'the result is a JSON object' };
|
|
134
|
+
const title = one(input.title, LIM.title), why = one(input.why, LIM.why);
|
|
135
|
+
if (!title || !why) return { error: 'title and why are each one short line' };
|
|
136
|
+
const s = srcList(input.sources, 1); if (s.error) return s;
|
|
137
|
+
const d = isObj(input.data) ? input.data : {}, data = {};
|
|
138
|
+
if (type === 'start') {
|
|
139
|
+
if (!one(d.title, LIM.title) || !ID_RE.test(str(d.board)) || !one(d.anchor, 80)) return { error: 'start needs data { title, board, anchor }: the new initiative, its board and what it counts toward' };
|
|
140
|
+
Object.assign(data, { title: one(d.title, LIM.title), board: d.board, anchor: one(d.anchor, 80), ...(str(d.stage) ? { stage: one(d.stage, 40) } : {}) });
|
|
141
|
+
}
|
|
142
|
+
if (str(d.refreshed)) data.refreshed = one(d.refreshed, LIM.checked);
|
|
143
|
+
const id = ID_RE.test(str(input.id)) ? input.id : 'lab-' + hash([type, title].join('|'));
|
|
144
|
+
return { prop: { id, type, title, why, sources: s.v, data } };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/* ── the Lab ledger ── */
|
|
148
|
+
const ledgerFile = (dir) => (dir ? path.join(dir, LEDGER) : null);
|
|
149
|
+
function readLines(file) {
|
|
150
|
+
if (!file) return [];
|
|
151
|
+
let text = '';
|
|
152
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
153
|
+
const out = [];
|
|
154
|
+
for (const raw of text.split('\n')) { if (!raw.trim()) continue; try { const l = JSON.parse(raw); if (isObj(l) && KINDS.has(l.kind)) out.push(l); } catch { /* a torn line is skipped */ } }
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
const read = (dir) => fold(readLines(ledgerFile(dir)));
|
|
158
|
+
function append(dir, actor, ev) {
|
|
159
|
+
const line = { at: new Date().toISOString(), by: (actor && actor.by) || null, ...ev };
|
|
160
|
+
fs.appendFileSync(ledgerFile(dir), JSON.stringify(line) + '\n');
|
|
161
|
+
return line;
|
|
162
|
+
}
|
|
163
|
+
function fold(lines) {
|
|
164
|
+
const props = new Map(), drops = new Map(), kept = [], undone = new Set(), runs = [], dropBatch = new Map();
|
|
165
|
+
let next = null, t = '';
|
|
166
|
+
for (const l of lines || []) {
|
|
167
|
+
if (!isObj(l) || !KINDS.has(l.kind)) continue;
|
|
168
|
+
if (l.at && String(l.at) > t) t = String(l.at);
|
|
169
|
+
if (l.kind === 'propose' && ID_RE.test(str(l.id)) && LOOSE.includes(l.type)) { props.set(l.id, { id: l.id, type: l.type, title: str(l.title), why: str(l.why), sources: Array.isArray(l.sources) ? l.sources : [], data: isObj(l.data) ? l.data : {}, at: str(l.at) }); drops.delete(l.id); }
|
|
170
|
+
else if (l.kind === 'drop' && str(l.id)) { drops.set(l.id, { why: one(l.why, LIM.why), at: str(l.at) }); if (str(l.batch)) dropBatch.set(l.batch, l.id); }
|
|
171
|
+
else if (l.kind === 'keep' && str(l.batch) && str(l.id)) kept.push({ id: l.id, batch: l.batch, wrote: Array.isArray(l.wrote) ? l.wrote.filter(isObj) : [], at: str(l.at) });
|
|
172
|
+
else if (l.kind === 'undo' && str(l.batch)) { undone.add(l.batch); if (dropBatch.has(l.batch)) drops.delete(dropBatch.get(l.batch)); }
|
|
173
|
+
else if (l.kind === 'run') runs.push({ job: one(l.job, 40), ms: Number(l.ms) > 0 ? Number(l.ms) : 0, n: Number(l.n) > 0 ? Number(l.n) : 0, at: str(l.at) });
|
|
174
|
+
else if (l.kind === 'next') next = { items: (Array.isArray(l.items) ? l.items : []).filter(isObj).slice(0, 3).map((x) => ({ kind: one(x.kind, 20), title: one(x.title, LIM.title), why: one(x.why, LIM.why) })).filter((x) => x.title), at: str(l.at) };
|
|
175
|
+
}
|
|
176
|
+
return { props, drops, kept, undone, runs, next, t };
|
|
177
|
+
}
|
|
178
|
+
const keptOf = (F) => { const m = new Map(); for (const k of F.kept) if (!F.undone.has(k.batch)) m.set(k.id, k); return m; };
|
|
179
|
+
/** The open Lab ✦, newest first. */
|
|
180
|
+
function looseOpen(F) {
|
|
181
|
+
const kept = keptOf(F);
|
|
182
|
+
return [...F.props.values()].filter((p) => !kept.has(p.id) && !F.drops.has(p.id)).sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* ── the Lab section (I1) ── */
|
|
186
|
+
const fmtMs = (ms) => { const m = Math.round(ms / 6e4); return m < 60 ? m + ' m' : Math.floor(m / 60) + ' h' + (m % 60 ? ' ' + (m % 60) + ' m' : ''); };
|
|
187
|
+
/**
|
|
188
|
+
* PURE. The Lab section: the ✦ that fit no initiative, the week's record of every background job (links only), the keep rate and what comes
|
|
189
|
+
* next. null when nothing ran this week and nothing waits (zero is silence; it is also how the Lab first appears only once there is something).
|
|
190
|
+
* P lib/proposals.js fold of this product (the idle-lab groups on its initiatives)
|
|
191
|
+
* L this file's fold
|
|
192
|
+
* extra [{ job, title, meta?, at, ini?, board? }]: what other background jobs did this week (grill runs, AI jobs, the shared queue)
|
|
193
|
+
* titleOf (ini) → the initiative's name, or ''
|
|
194
|
+
*/
|
|
195
|
+
function labOf({ P, L, extra = [], now = Date.now(), titleOf = () => '' } = {}) {
|
|
196
|
+
L = L || fold([]);
|
|
197
|
+
const since = now - WEEK, inWeek = (at) => (Date.parse(at) || 0) >= since;
|
|
198
|
+
const keptP = new Set(); if (P) for (const k of P.kept) if (!P.undone.has(k.batch)) for (const id of k.ids) keptP.add(id);
|
|
199
|
+
const record = [], tally = { proto: [0, 0], scan: [0, 0] };
|
|
200
|
+
const PRsig = (() => { try { return require('./proposals.js').sigOf; } catch { return null; } })();
|
|
201
|
+
for (const p of P ? P.props.values() : []) {
|
|
202
|
+
if (!GROUPS.includes(p.group) || !inWeek(p.at)) continue;
|
|
203
|
+
const state = keptP.has(p.id) ? 'kept' : PRsig && P.drops.has(PRsig(p)) ? 'dropped' : 'open';
|
|
204
|
+
const t = p.group === 'scanned' ? tally.scan : tally.proto;
|
|
205
|
+
t[1]++; if (state === 'kept') t[0]++;
|
|
206
|
+
const name = titleOf(p.ini) || p.ini, d = p.data || {};
|
|
207
|
+
record.push({ kind: p.group, id: p.id, ini: p.ini, board: p.board || '', at: p.at, state, why: state === 'dropped' ? dropWhy(P, PRsig(p)) : '',
|
|
208
|
+
title: p.group === 'prototyped' ? plural((d.specimens || []).length, 'prototype', 'prototypes') + ' · ' + name : p.group === 'improved' ? 'Round ' + d.round + ' · ' + name : 'Scan · ' + name,
|
|
209
|
+
meta: p.group === 'prototyped' ? 'round 1 · ' + (d.checked || '') : p.group === 'improved' ? d.changed || '' : plural(d.pages || (p.sources || []).length, 'page', 'pages') + ' read',
|
|
210
|
+
compare: p.group !== 'scanned' });
|
|
211
|
+
}
|
|
212
|
+
const keptL = keptOf(L), open = looseOpen(L);
|
|
213
|
+
for (const p of L.props.values()) {
|
|
214
|
+
if (!inWeek(p.at)) continue;
|
|
215
|
+
const state = keptL.has(p.id) ? 'kept' : L.drops.has(p.id) ? 'dropped' : 'open';
|
|
216
|
+
tally.scan[1]++; if (state === 'kept') tally.scan[0]++;
|
|
217
|
+
record.push({ kind: 'loose', id: p.id, at: p.at, state, why: state === 'dropped' ? (L.drops.get(p.id).why || '') : '', title: 'Scan · ' + p.title, meta: plural((p.sources || []).length, 'page', 'pages') + ' read' });
|
|
218
|
+
}
|
|
219
|
+
for (const x of extra || []) if (x && inWeek(x.at) && one(x.title, LIM.title)) record.push({ kind: 'job', job: one(x.job, 40), at: x.at, state: 'done', title: one(x.title, LIM.title), meta: one(x.meta, LIM.why), ...(x.ini ? { ini: x.ini, board: x.board || '' } : {}) });
|
|
220
|
+
record.sort((a, b) => String(b.at).localeCompare(String(a.at)));
|
|
221
|
+
const next = L.next && L.next.items.length ? L.next.items : [];
|
|
222
|
+
if (!record.length && !open.length && !next.length) return null;
|
|
223
|
+
const filed = tally.proto[1] + tally.scan[1], kept = tally.proto[0] + tally.scan[0];
|
|
224
|
+
const idle = L.runs.filter((r) => inWeek(r.at)).reduce((s, r) => s + r.ms, 0);
|
|
225
|
+
const say = [idle ? 'This week I used ' + fmtMs(idle) + ' of your idle time.' : '', filed ? 'You kept ' + kept + ' of ' + filed + '.' : '',
|
|
226
|
+
open.length ? plural(open.length, 'finding fits', 'findings fit') + ' no initiative yet.' : ''].filter(Boolean).join(' ');
|
|
227
|
+
return { say, loose: open.map((p) => ({ id: p.id, type: p.type, title: p.title, why: p.why, sources: p.sources, data: p.data, at: p.at })),
|
|
228
|
+
record: record.slice(0, 12), more: Math.max(0, record.length - 12),
|
|
229
|
+
kept: filed ? { kept, filed, proto: tally.proto, scan: tally.scan } : null, next };
|
|
230
|
+
}
|
|
231
|
+
function dropWhy(P, sig) {
|
|
232
|
+
// the reason rides on the drop line (lib/routes-proposals.js keeps body.why); the fold keeps only the signature, so read it back here
|
|
233
|
+
return (P && P.dropWhy && P.dropWhy.get(sig)) || '';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* ── the compare view (I3) ── */
|
|
237
|
+
/**
|
|
238
|
+
* PURE. One prototype in the compare view: production (from the product repo's main), the design, the prototype round shown, and History.
|
|
239
|
+
* A column with no proven source is left out with one line why; a pin against a left-out column is not drawn.
|
|
240
|
+
* P lib/proposals.js fold; id: a prototyped or improved proposal id; key: the specimen to show (a first round), default the first
|
|
241
|
+
* → { ini, board, id, sig, state: open | kept | dropped, title, round, key, say, columns: [{ id, title, meta, file?, read?, left? }], pins: [{ n, vs, text }], history: [{ id, round, at, state, meta, current }] } | null
|
|
242
|
+
*/
|
|
243
|
+
function compareOf(P, id, { key = '', titleOf = () => '', proven = PROVEN } = {}) {
|
|
244
|
+
if (!P) return null;
|
|
245
|
+
const p = P.props.get(id);
|
|
246
|
+
if (!p || (p.group !== 'prototyped' && p.group !== 'improved')) return null;
|
|
247
|
+
const root = p.group === 'prototyped' ? p : P.props.get(p.data && p.data.of);
|
|
248
|
+
const keptP = new Set(); for (const k of P.kept) if (!P.undone.has(k.batch)) for (const x of k.ids) keptP.add(x);
|
|
249
|
+
const PRsig = require('./proposals.js').sigOf;
|
|
250
|
+
const stateOf = (x) => (keptP.has(x.id) ? 'kept' : P.drops.has(PRsig(x)) ? 'dropped' : 'open');
|
|
251
|
+
const rounds = [...P.props.values()].filter((x) => x.group === 'improved' && x.ini === p.ini && root && x.data && x.data.of === root.id).sort((a, b) => a.data.round - b.data.round);
|
|
252
|
+
const specs = root ? root.data.specimens || [] : [];
|
|
253
|
+
const k = p.group === 'improved' ? p.data.base : (specs.find((s) => s.key === key) || specs[0] || {}).key || '';
|
|
254
|
+
const spec = specs.find((s) => s.key === k) || null;
|
|
255
|
+
const file = p.group === 'improved' ? p.data.file : spec && spec.file;
|
|
256
|
+
if (!file) return null;
|
|
257
|
+
const round = p.group === 'improved' ? p.data.round : 1;
|
|
258
|
+
const c = (p.data && p.data.compare) || {};
|
|
259
|
+
const cols = [];
|
|
260
|
+
cols.push(proven.production && c.production ? { id: 'production', title: 'Production', meta: 'main, today', file: c.production.file, read: c.production.read }
|
|
261
|
+
: { id: 'production', title: 'Production', left: 'Left out: Claude cannot render production from the product repo yet. It shows here once that is proven.' });
|
|
262
|
+
cols.push(proven.design && c.design ? { id: 'design', title: 'Design', meta: 'the design for this screen', file: c.design.file, read: c.design.read }
|
|
263
|
+
: { id: 'design', title: 'Design', left: 'Left out: Claude cannot read the design from Figma or a canvas yet. It shows here once that is proven.' });
|
|
264
|
+
cols.push({ id: 'prototype', title: 'Prototype', meta: 'round ' + round + (spec ? ' · ' + spec.key : ''), file, read: (p.data && p.data.checked) || '' });
|
|
265
|
+
const shown = new Set(cols.filter((x) => !x.left).map((x) => x.id));
|
|
266
|
+
const pins = (c.pins || []).filter((x) => shown.has(x.vs)).map((x, i) => ({ n: i + 1, vs: x.vs, text: x.text }));
|
|
267
|
+
const history = [];
|
|
268
|
+
for (const r of rounds.slice().reverse()) history.push({ id: r.id, round: r.data.round, at: r.at, state: stateOf(r), meta: r.data.changed || '', current: r.id === p.id });
|
|
269
|
+
if (root) history.push({ id: root.id, round: 1, at: root.at, state: stateOf(root), meta: specs.map((s) => s.key).join(', ') + (specs.length ? ': ' : '') + specs.map((s) => s.title).join(' · '), current: root.id === p.id, keys: specs.map((s) => ({ key: s.key, title: s.title })) });
|
|
270
|
+
const name = titleOf(p.ini) || p.ini;
|
|
271
|
+
const say = p.group === 'improved' ? 'Round ' + round + ' of ' + k + ': ' + p.data.changed : (spec ? spec.title + (spec.caption ? ': ' + spec.caption : '') : '');
|
|
272
|
+
return { ini: p.ini, board: p.board || '', id: p.id, sig: PRsig(p), state: stateOf(p), title: name, round, key: k, say, columns: cols, pins, history };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
module.exports = { LEDGER, GROUPS, TITLES, LOOSE, PROVEN, specimenDir, specimenPath, specimenUrl, result, loose, ledgerFile, readLines, read, append, fold, looseOpen, labOf, compareOf, fmtMs };
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* moments.js — a feature appears when its moment comes (onboarding build B3, design ObA4Gradual; brief
|
|
3
|
+
* /mnt/project-files/onboarding-grill/brief-B3-gradual-exposure.md). lib/routes-moments.js does the HTTP; pm/home-moment.js draws the ✦ in
|
|
4
|
+
* Home's own chat, pm/cozy-nav.js the soft dot on a place's menu row, pm/quiet-place.js the one honest line of a place that is not open yet.
|
|
5
|
+
*
|
|
6
|
+
* STATE, NOT DAYS. Each moment opens on a fact PMOS can check in the workspace's own records, never on a timer:
|
|
7
|
+
* first the first initiative, kept or dropped on the after-setup reveal (B2, lib/first-reveal.js); without that module, setup done and
|
|
8
|
+
* one initiative. Recorded only: B2 draws its card.
|
|
9
|
+
* board 3 open initiatives, or a date on a program (its target or a milestone). place 'board'
|
|
10
|
+
* insights an OKR file with at least one key result: a goal is kept. place 'insights'
|
|
11
|
+
* reports a week of movement: the oldest initiative record is 7 days old or more, and a record changed in the last 7 days.
|
|
12
|
+
* place 'reports'
|
|
13
|
+
* ONE AT A TIME. At most one moment is proposed per day, across all workspaces. It is the day's moment until the PM answers it: ✓ keeps it
|
|
14
|
+
* (the page opens the place), ✕ drops it, and a dropped moment never comes back. After an answer, the next moment waits for the next day.
|
|
15
|
+
*
|
|
16
|
+
* ONLY NEW WORKSPACES. A workspace counts as new when setup ran for it (lib/onboarding-progress.js `started`, or the project-first
|
|
17
|
+
* conversation's `done.ws`, lib/routes-onboard.js). Any other workspace was already in use before this existed: every place is open for it, it
|
|
18
|
+
* gets no ✦ and no dot, and a page is quiet only when every registered workspace is new and none has opened that place.
|
|
19
|
+
*
|
|
20
|
+
* WHERE IT LIVES. The workspace's onboarding-progress record carries { moments: { [id]: { shown, kept?, dropped? } },
|
|
21
|
+
* places: { [place]: { open, visited? } } } beside its setup steps (saveMoments keeps `started` as it was).
|
|
22
|
+
*
|
|
23
|
+
* Everything here reads files; nothing calls a model, a connector or the network. */
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const progress = require('./onboarding-progress');
|
|
27
|
+
|
|
28
|
+
const WEEK = 7 * 86400000;
|
|
29
|
+
const PLACES = ['board', 'insights', 'reports'];
|
|
30
|
+
/** The moments in the order they are proposed. `place` is where ✓ goes; `guide` the guide page linked inline. */
|
|
31
|
+
const MOMENTS = [
|
|
32
|
+
{ id: 'first', place: null, record: true },
|
|
33
|
+
{ id: 'board', place: 'board', href: '/premium-board.html', guide: 'programs', guideLabel: 'How the Board works', go: 'Open the Board' },
|
|
34
|
+
{ id: 'insights', place: 'insights', href: '/premium-intelligence.html', guide: 'intelligence-okr-goals', guideLabel: 'How Intelligence works', go: 'Open Intelligence' },
|
|
35
|
+
{ id: 'reports', place: 'reports', href: '/premium-intelligence.html?tab=library', guide: 'reports', guideLabel: 'How reports work', go: 'Open reports' },
|
|
36
|
+
];
|
|
37
|
+
/** The one honest line a place shows before its moment comes. */
|
|
38
|
+
const QUIET = {
|
|
39
|
+
board: 'The Board starts at three initiatives, or a date.',
|
|
40
|
+
insights: 'Intelligence starts when you keep a goal.',
|
|
41
|
+
reports: 'The first report comes after a week of movement.',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const INITIATIVE_DIRS = [['pmos', 'state', 'initiatives'], ['planning', 'initiatives'], ['pmos', 'planning', 'initiatives']];
|
|
45
|
+
const ls = (d) => { try { return fs.readdirSync(d); } catch { return []; } };
|
|
46
|
+
const fmOf = (text) => {
|
|
47
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text || '');
|
|
48
|
+
const out = {};
|
|
49
|
+
if (m) for (const l of m[1].split(/\r?\n/)) { const x = /^([a-z_]+):\s*(.*)$/.exec(l); if (x) out[x[1]] = x[2].replace(/^["']|["']$/g, '').trim(); }
|
|
50
|
+
return out;
|
|
51
|
+
};
|
|
52
|
+
const readLines = (f) => { try { return fs.readFileSync(f, 'utf8').split('\n').filter((l) => l.trim()).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } };
|
|
53
|
+
const DATE = /^\d{4}-\d{2}-\d{2}/;
|
|
54
|
+
|
|
55
|
+
/** The initiative record folders of a workspace: the repository's, and its state folder's (a kit install). */
|
|
56
|
+
function dirsOf(ctx, w) {
|
|
57
|
+
const dirs = INITIATIVE_DIRS.map((d) => path.join(w.root, ...d));
|
|
58
|
+
try { const s = ctx.stateDirFor && ctx.stateDirFor(w); if (s) dirs.push(path.join(s, 'initiatives')); } catch { /* no state folder */ }
|
|
59
|
+
return [...new Set(dirs.map((d) => path.resolve(d)))];
|
|
60
|
+
}
|
|
61
|
+
function stateDirsOf(ctx, w) {
|
|
62
|
+
const dirs = [path.join(w.root, 'pmos', 'state')];
|
|
63
|
+
try { const s = ctx.stateDirFor && ctx.stateDirFor(w); if (s) dirs.push(s); } catch { /* none */ }
|
|
64
|
+
return [...new Set(dirs.map((d) => path.resolve(d)))];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What a workspace's records say, read from files. → { initiatives, dated, goal, goalTitle, week } */
|
|
68
|
+
function facts(ctx, w, now = Date.now()) {
|
|
69
|
+
// open initiatives: a record that is not a README or template, not stopped by its last lifecycle line (D70)
|
|
70
|
+
const stopped = new Map();
|
|
71
|
+
for (const f of [path.join(w.root, 'planning', 'runs', 'lifecycle.jsonl'), path.join(w.root, 'pmos', 'planning', 'runs', 'lifecycle.jsonl')]) {
|
|
72
|
+
for (const e of readLines(f)) if (e.initiative_id) stopped.set(e.initiative_id, e.state);
|
|
73
|
+
}
|
|
74
|
+
const ids = new Set(); let oldest = Infinity, newest = 0;
|
|
75
|
+
for (const d of dirsOf(ctx, w)) {
|
|
76
|
+
for (const n of ls(d)) {
|
|
77
|
+
if (!n.endsWith('.md') || /^(README|index|log)\.md$/i.test(n) || /template/i.test(n)) continue;
|
|
78
|
+
const f = path.join(d, n);
|
|
79
|
+
let text = '', st = null;
|
|
80
|
+
try { text = fs.readFileSync(f, 'utf8'); st = fs.statSync(f); } catch { continue; }
|
|
81
|
+
const id = fmOf(text).id || n.replace(/\.md$/, '');
|
|
82
|
+
const state = stopped.get(id);
|
|
83
|
+
if (state && state !== 'active') continue;
|
|
84
|
+
ids.add(id);
|
|
85
|
+
oldest = Math.min(oldest, st.mtimeMs); newest = Math.max(newest, st.mtimeMs);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// a date on a program: its target, or a milestone's date (lib/programs.js; <state>/programs.jsonl)
|
|
89
|
+
let dated = false;
|
|
90
|
+
for (const s of stateDirsOf(ctx, w)) {
|
|
91
|
+
for (const l of readLines(path.join(s, 'programs.jsonl'))) {
|
|
92
|
+
if (l.kind !== 'program') continue;
|
|
93
|
+
if (DATE.test(String(l.target || '')) || (Array.isArray(l.milestones) && l.milestones.some((m) => m && DATE.test(String(m.date || ''))))) dated = true;
|
|
94
|
+
}
|
|
95
|
+
for (const n of ls(s)) { if (!/\.jsonl$/.test(n)) continue; try { newest = Math.max(newest, fs.statSync(path.join(s, n)).mtimeMs); } catch { /* gone */ } }
|
|
96
|
+
}
|
|
97
|
+
// a goal kept: an OKR file with an objective that has a key result (lib/okr-goals.js)
|
|
98
|
+
let goal = false, goalTitle = '';
|
|
99
|
+
try { const r = require('./okr-goals.js').read(w.root); if (r.ok && r.objectives.length) { goal = true; goalTitle = String(r.objectives[0].title || '').slice(0, 120); } } catch { /* unreadable: no goal */ }
|
|
100
|
+
const week = ids.size > 0 && Number.isFinite(oldest) && oldest <= now - WEEK && newest >= now - WEEK;
|
|
101
|
+
return { initiatives: ids.size, dated, goal, goalTitle, week };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Which places the facts open. */
|
|
105
|
+
function openOf(F) { return { board: F.initiatives >= 3 || F.dated, insights: !!F.goal, reports: !!F.week }; }
|
|
106
|
+
|
|
107
|
+
/** Whether setup ran for this workspace. */
|
|
108
|
+
function isNew(ctx, w) {
|
|
109
|
+
try { if (progress.read(ctx.runtimeDir, w.id).started) return true; } catch { /* no record */ }
|
|
110
|
+
try {
|
|
111
|
+
const st = JSON.parse(fs.readFileSync(path.join(ctx.runtimeDir, 'onboarding', 'project.json'), 'utf8'));
|
|
112
|
+
return !!(st && st.done && st.done.ws === w.id);
|
|
113
|
+
} catch { return false; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const dayOf = (now) => { const d = new Date(now); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); };
|
|
117
|
+
|
|
118
|
+
/** Whether moment m is due for a workspace with facts F and setup record P. */
|
|
119
|
+
function due(m, F, P, open) {
|
|
120
|
+
if (m.id === 'first') return !!P.completed && F.initiatives >= 1;
|
|
121
|
+
return !!open[m.place];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The words of a moment's ✦, from the facts that opened it. */
|
|
125
|
+
function wordsOf(m, F) {
|
|
126
|
+
if (m.id === 'board') {
|
|
127
|
+
return F.initiatives >= 3
|
|
128
|
+
? { text: `${F.initiatives === 3 ? 'Three' : F.initiatives} initiatives now. See them by stage on the Board, and rank them.`, sub: 'One ranked list, in the order you set. Programs and the timeline follow once a program or a date exists.' }
|
|
129
|
+
: { text: 'A date is set now. See your initiatives on the Board, with the timeline, and rank them.', sub: 'One ranked list, in the order you set.' };
|
|
130
|
+
}
|
|
131
|
+
if (m.id === 'insights') return { text: 'You kept a goal. Intelligence reads it now: its key results and where each number comes from.', sub: F.goalTitle ? 'Goal: ' + F.goalTitle : '' };
|
|
132
|
+
if (m.id === 'reports') return { text: 'A week of movement now. The first report can be drafted from it.', sub: 'Reports and dashboards are in Intelligence, under ⋯.' };
|
|
133
|
+
return { text: '', sub: '' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The whole view, and the writes it implies. Pure over its inputs: `rows` is one entry per workspace
|
|
138
|
+
* { w, isNew, facts, progress, reveal? }. → { moment, places, writes }
|
|
139
|
+
* moment the day's ✦ or null: { id, ws, product, text, sub, href, guide, guideLabel, go }
|
|
140
|
+
* places { [place]: { quiet, dot, line } }
|
|
141
|
+
* writes [{ ws, moments?, places? }] what to save (the first moment recorded, a place that opened)
|
|
142
|
+
*/
|
|
143
|
+
function view(rows, now = Date.now()) {
|
|
144
|
+
const today = dayOf(now);
|
|
145
|
+
const fresh = rows.filter((r) => r.isNew);
|
|
146
|
+
const writes = [];
|
|
147
|
+
const places = {};
|
|
148
|
+
for (const p of PLACES) {
|
|
149
|
+
const open = rows.some((r) => !r.isNew || openOf(r.facts)[p]);
|
|
150
|
+
places[p] = { quiet: rows.length > 0 && !open, dot: false, line: QUIET[p] };
|
|
151
|
+
}
|
|
152
|
+
let answeredToday = false, current = null;
|
|
153
|
+
for (const r of fresh) {
|
|
154
|
+
const P = r.progress || {}, M = { ...(P.moments || {}) }, PL = { ...(P.places || {}) }, open = openOf(r.facts);
|
|
155
|
+
let changed = false;
|
|
156
|
+
for (const p of PLACES) {
|
|
157
|
+
if (open[p] && !(PL[p] && PL[p].open)) { PL[p] = { open: new Date(now).toISOString() }; changed = true; }
|
|
158
|
+
if (PL[p] && PL[p].open && !PL[p].visited && !(M[p] && M[p].dropped)) places[p].dot = true;
|
|
159
|
+
}
|
|
160
|
+
// the first initiative: B2's reveal card records the PM's ✓ or ✕ (lib/first-reveal.js); before it ships, setup done + one initiative
|
|
161
|
+
const R = r.reveal;
|
|
162
|
+
if (!M.first && R && (R.kept || R.dropped)) { M.first = R.kept ? { shown: today, kept: R.kept.at || new Date(now).toISOString() } : { shown: today, dropped: (R.dropped && R.dropped.at) || new Date(now).toISOString() }; changed = true; }
|
|
163
|
+
else if (!M.first && !R && due(MOMENTS[0], r.facts, P, open)) { M.first = { shown: today, kept: new Date(now).toISOString() }; changed = true; }
|
|
164
|
+
if (changed) writes.push({ ws: r.w.id, moments: M, places: PL });
|
|
165
|
+
for (const m of MOMENTS) {
|
|
166
|
+
const s = M[m.id];
|
|
167
|
+
if (!s || m.record) continue;
|
|
168
|
+
if (s.shown === today && (s.kept || s.dropped)) answeredToday = true;
|
|
169
|
+
if (!s.kept && !s.dropped && !current) current = { m, r };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (!current && !answeredToday) {
|
|
173
|
+
for (const r of fresh) {
|
|
174
|
+
const P = r.progress || {}, M = P.moments || {}, open = openOf(r.facts);
|
|
175
|
+
const m = MOMENTS.find((x) => !x.record && !M[x.id] && due(x, r.facts, P, open));
|
|
176
|
+
if (m) { current = { m, r }; break; }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
let moment = null;
|
|
180
|
+
if (current) {
|
|
181
|
+
const { m, r } = current;
|
|
182
|
+
const W = wordsOf(m, r.facts);
|
|
183
|
+
moment = { id: m.id, ws: r.w.id, product: fresh.length > 1 ? (r.w.name || r.w.id) : '', text: W.text, sub: W.sub, href: m.href, guide: m.guide, guideLabel: m.guideLabel, go: m.go, shown: !!(r.progress && r.progress.moments && r.progress.moments[m.id]) };
|
|
184
|
+
}
|
|
185
|
+
return { moment, places, writes };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The after-setup reveal's answer on the first initiative (B2, lib/first-reveal.js read(runtimeDir, ws) → { kept } | { dropped } | null).
|
|
189
|
+
* null while that module is not installed, or nothing was answered. */
|
|
190
|
+
function revealOf(ctx, w) {
|
|
191
|
+
try { const r = require('./first-reveal.js').read(ctx.runtimeDir, w.id); return r && (r.kept || r.dropped) ? r : null; } catch { return null; }
|
|
192
|
+
}
|
|
193
|
+
/** The rows view() reads, from the registered workspaces. */
|
|
194
|
+
function rowsOf(ctx, now = Date.now()) {
|
|
195
|
+
const list = (ctx.workspaceList ? ctx.workspaceList() : []).filter((w) => w && w.id && w.root);
|
|
196
|
+
return list.map((w) => {
|
|
197
|
+
const fresh = isNew(ctx, w);
|
|
198
|
+
return { w, isNew: fresh, facts: fresh ? facts(ctx, w, now) : null, progress: fresh ? progress.read(ctx.runtimeDir, w.id) : null, reveal: fresh ? revealOf(ctx, w) : null };
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Read, save what opened, answer. → { moment, places } */
|
|
203
|
+
function current(ctx, now = Date.now()) {
|
|
204
|
+
const rows = rowsOf(ctx, now);
|
|
205
|
+
const v = view(rows, now);
|
|
206
|
+
for (const x of v.writes) progress.saveMoments(ctx.runtimeDir, x.ws, { moments: x.moments, places: x.places });
|
|
207
|
+
return { moment: v.moment, places: v.places };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The PM's answer, or the card shown: shown | keep | drop. → { ok } | { ok: false, status, error } */
|
|
211
|
+
function answer(ctx, verb, ws, id, now = Date.now()) {
|
|
212
|
+
const m = MOMENTS.find((x) => x.id === id && !x.record);
|
|
213
|
+
if (!m) return { ok: false, status: 400, error: 'There is no such moment.' };
|
|
214
|
+
const w = ctx.getWs && ctx.getWs(ws);
|
|
215
|
+
if (!w || w.id !== ws || !isNew(ctx, w)) return { ok: false, status: 404, error: 'That workspace has no moments.' };
|
|
216
|
+
const P = progress.read(ctx.runtimeDir, w.id), M = { ...(P.moments || {}) }, PL = { ...(P.places || {}) };
|
|
217
|
+
const s = { ...(M[id] || {}) };
|
|
218
|
+
if (s.dropped) return { ok: true };
|
|
219
|
+
if (!s.shown || verb === 'shown') s.shown = s.shown || dayOf(now);
|
|
220
|
+
const at = new Date(now).toISOString();
|
|
221
|
+
if (verb === 'keep') { s.kept = at; s.shown = dayOf(now); if (m.place && PL[m.place]) PL[m.place] = { ...PL[m.place], visited: PL[m.place].visited || at }; }
|
|
222
|
+
if (verb === 'drop') { s.dropped = at; s.shown = dayOf(now); }
|
|
223
|
+
M[id] = s;
|
|
224
|
+
progress.saveMoments(ctx.runtimeDir, w.id, { moments: M, places: PL });
|
|
225
|
+
return { ok: true };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The PM opened a place: its dot clears in every new workspace where it opened. */
|
|
229
|
+
function visit(ctx, place, now = Date.now()) {
|
|
230
|
+
if (!PLACES.includes(place)) return { ok: false, status: 400, error: 'place is board, insights or reports' };
|
|
231
|
+
for (const r of rowsOf(ctx, now)) {
|
|
232
|
+
if (!r.isNew) continue;
|
|
233
|
+
const PL = { ...((r.progress && r.progress.places) || {}) };
|
|
234
|
+
if (!PL[place] || !PL[place].open || PL[place].visited) continue;
|
|
235
|
+
PL[place] = { ...PL[place], visited: new Date(now).toISOString() };
|
|
236
|
+
progress.saveMoments(ctx.runtimeDir, r.w.id, { places: PL });
|
|
237
|
+
}
|
|
238
|
+
return { ok: true };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = { MOMENTS, PLACES, QUIET, facts, openOf, isNew, view, rowsOf, current, answer, visit, dayOf };
|