create-pmos 0.239.0 → 0.241.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 +1 -1
- 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/c3-report-structure-real-test.js +211 -0
- package/workspace/c3-report-structure-test.js +382 -0
- package/workspace/guide/briefing.md +31 -3
- package/workspace/guide/index.md +2 -0
- package/workspace/guide/report-design.md +4 -0
- package/workspace/guide/report-structure.md +91 -0
- package/workspace/guide/reports-dashboards.md +1 -1
- package/workspace/guide/reports-set-up.md +2 -0
- package/workspace/lib/charts-store.js +124 -0
- package/workspace/lib/headless.js +17 -3
- package/workspace/lib/needs-providers.js +1 -0
- package/workspace/lib/report-structure-needs.js +64 -0
- package/workspace/lib/report-structure.js +515 -0
- package/workspace/lib/route-modules.js +3 -1
- package/workspace/lib/routes-brief-page.js +56 -5
- package/workspace/lib/routes-mentions.js +37 -0
- package/workspace/lib/routes-report-setups.js +11 -6
- package/workspace/lib/routes-report-structure.js +619 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/briefing-deck.js +1 -0
- package/workspace/public/pm/c3-brief-mentions.js +136 -0
- package/workspace/public/pm/c3-brief-needs.js +23 -0
- package/workspace/public/pm/c3-brief-pulse.js +1 -0
- package/workspace/public/pm/c3-brief-summary.js +139 -0
- package/workspace/public/pm/c3-demo-brief-page.js +20 -4
- package/workspace/public/pm/c3-demo-rd.js +4 -0
- package/workspace/public/pm/c3-design-studio.js +7 -3
- package/workspace/public/pm/c3-report-setup.js +3 -0
- package/workspace/public/pm/c3-report-structure.css +40 -0
- package/workspace/public/pm/c3-report-structure.js +455 -0
- package/workspace/public/pm/c3-reports.css +2 -0
- package/workspace/public/pm/c3-reports.js +22 -3
- package/workspace/public/pm/c3-shell.js +34 -2
- package/workspace/public/pm/console3.css +12 -0
- package/workspace/server.js +38 -9
- package/workspace/skills/report-source-check.skill +45 -0
- package/workspace/skills/report-structure-dry.skill +51 -0
- package/workspace/skills/report-structure.skill +61 -0
- package/workspace/skills/report-template.skill +10 -0
- package/workspace/test-files.json +2 -0
- package/workspace/test-lists/report-structure-backend.json +5 -0
- package/workspace/test-lists/report-structure-ui.json +11 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* report-structure.js — the report structure step's file store, fold, validators and brief (initiative report-structure, contract sections 1 to 5).
|
|
3
|
+
*
|
|
4
|
+
* paths(root, id) → { abs, rel, folder, dir, events, versions, pending, dry, checks, checksPending, brief, briefRel } <state>/reports/<id>/structure/…
|
|
5
|
+
* append(root, id, line, { actor, now }) → the line written. THE SINGLE WRITER of structure.jsonl. `at` and `actor` are set here.
|
|
6
|
+
* readEvents(root, id) · fold(events) → the current state (see FOLD) · stateOf(root, id)
|
|
7
|
+
* validatePlan(plan, { charts }) → { ok, plan } | { ok: false, status, error } the plan of section 3, cleaned (group derived, fixed sections set)
|
|
8
|
+
* validateSource(source) → { ok, source } | refusal one source the PM adds (no `state`; the server sets it)
|
|
9
|
+
* validateDry(dry, plan) → { ok, dry } | refusal a dry-run result (section 4)
|
|
10
|
+
* validateCheck(check, kind) → { ok, steps } | refusal the steps a check session wrote (section 5)
|
|
11
|
+
* landVersion(root, id, plan, meta) → { ok, n, plan } writes versions/S<n>.json and the version line (plus any `before` lines)
|
|
12
|
+
* readVersion(root, id, n) → the plan of S<n> | null
|
|
13
|
+
* rewriteVersion(root, id, n, plan) → the plan written back to S<n>.json (accept links chart ids)
|
|
14
|
+
* writeDry · readDry · writeCheck · readCheck · pendingCheck
|
|
15
|
+
* writeBrief(root, id, setup, { now }) → { ok, path } | refusal brief.md (type Reference): the saved plan in words
|
|
16
|
+
* structureAnswer(root, id, opts) → the `structure` object GET /api/report-structure answers
|
|
17
|
+
* savedOf(root, id) → { saved, at, charts, sections } | null the setups GET carries this per setup
|
|
18
|
+
* addSourceTo(plan, source, opts) · removeFrom(plan, kind, ref) → the next plan (not yet validated) | refusal
|
|
19
|
+
*
|
|
20
|
+
* THE LINES of structure.jsonl. Every line is { ev, …, at, actor }. `actor` is the guard's actor for a press, or { by: 'pmos', via: 'job' } for a job:
|
|
21
|
+
* describe {text} · turn {text, base, job} · version {n, turn, answer, questions?, options?, from?} · accept {n, charts: [ids]} · revert {n}
|
|
22
|
+
* source {n, source} (the new source, with the version it made) · remove {n, kind, ref} · dry {run, n, ok} · check {source, ok, steps} · error {text, mode}
|
|
23
|
+
*
|
|
24
|
+
* FOLD. { description, log: [{ who: pm|pmos, text, at, n?, questions, options }], versions: Map n → meta, current, saved: { n, at } | null, dry: {run, n, at, ok} | null,
|
|
25
|
+
* errors: [], turns }. A torn line is skipped.
|
|
26
|
+
*
|
|
27
|
+
* THE VALIDATORS are the trust boundary for a job's output (PRD done-criterion 9): a plan or a dry result that holds an unknown kind, no fixed
|
|
28
|
+
* section, more than a limit, a script, an on* handler, javascript: or a URL in a text is refused and nothing lands. A URL is allowed in
|
|
29
|
+
* sources[].ref of a `link` or a `chrome` source and nowhere else.
|
|
30
|
+
*
|
|
31
|
+
* NOTHING HERE STARTS A JOB OR SENDS ANYTHING. lib/routes-report-structure.js starts the jobs and the check sessions and calls these writers.
|
|
32
|
+
*/
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const path = require('path');
|
|
35
|
+
const crypto = require('crypto');
|
|
36
|
+
const RD = require('./report-draft.js');
|
|
37
|
+
const CS = require('./charts-store.js');
|
|
38
|
+
|
|
39
|
+
const EVENTS = Object.freeze(['describe', 'turn', 'version', 'accept', 'revert', 'source', 'remove', 'dry', 'check', 'error']);
|
|
40
|
+
const PMOS_KINDS = Object.freeze(['pmos-level', 'dashboard', 'chart', 'document', 'past-report']);
|
|
41
|
+
const OUTSIDE_KINDS = Object.freeze(['connector', 'link', 'upload', 'person', 'chrome', 'computer']);
|
|
42
|
+
const KINDS = Object.freeze([...PMOS_KINDS, ...OUTSIDE_KINDS]);
|
|
43
|
+
const STATES = Object.freeze(['reached', 'not-connected', 'later', 'needs-you', 'off']);
|
|
44
|
+
const FIXED = Object.freeze(['headline', 'needs', 'sources']);
|
|
45
|
+
const LIMITS = Object.freeze({ sources: 12, charts: 16, sections: 10, options: 3, questions: 4, chips: 4, text: 600, turn: 4000 });
|
|
46
|
+
const CHECK_STATES = Object.freeze(['works', 'needs-you', 'waiting']);
|
|
47
|
+
const CHECK_STEPS = Object.freeze({
|
|
48
|
+
chrome: Object.freeze([['mac', 'PMOS runs on this Mac'], ['session', 'A session can start with Claude in Chrome'], ['extension', 'The Chrome extension answers'],
|
|
49
|
+
['signed-in', 'You are signed in'], ['read', 'PMOS can read the numbers']]),
|
|
50
|
+
computer: Object.freeze([['mac', 'PMOS runs on this Mac'], ['session', 'A session can start with computer use'], ['permission', 'macOS allows screen and input'],
|
|
51
|
+
['signed-in', 'You are signed in'], ['read', 'PMOS can read the numbers']]),
|
|
52
|
+
});
|
|
53
|
+
const CADENCE_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
54
|
+
const SLUG_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
55
|
+
const RUN_RE = /^[0-9a-f]{10}$/;
|
|
56
|
+
const PMOS_ACTOR = Object.freeze({ by: 'pmos', via: 'job' });
|
|
57
|
+
|
|
58
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
59
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
60
|
+
const refuse = (status, error) => ({ ok: false, status, error });
|
|
61
|
+
const iso = (now) => new Date(now == null ? Date.now() : now).toISOString();
|
|
62
|
+
const newRun = () => crypto.randomBytes(5).toString('hex');
|
|
63
|
+
const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\r\n?/g, '\n').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
|
64
|
+
|
|
65
|
+
function writeAtomic(file, data) {
|
|
66
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
67
|
+
const tmp = `${file}.tmp-${crypto.randomBytes(4).toString('hex')}`;
|
|
68
|
+
fs.writeFileSync(tmp, data);
|
|
69
|
+
fs.renameSync(tmp, file);
|
|
70
|
+
}
|
|
71
|
+
function readJson(file) { try { const o = JSON.parse(fs.readFileSync(file, 'utf8')); return isObj(o) ? o : null; } catch { return null; } }
|
|
72
|
+
|
|
73
|
+
// ── paths ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
function stateRel(root) { try { return RD.stateRel(root); } catch { return 'planning'; } }
|
|
75
|
+
function paths(root, id) {
|
|
76
|
+
if (!CADENCE_RE.test(String(id || ''))) throw new Error('report-structure: not a report id: ' + JSON.stringify(id));
|
|
77
|
+
const abs = path.resolve(String(root));
|
|
78
|
+
const rel = `${stateRel(abs)}/reports/${id}`;
|
|
79
|
+
const folder = path.join(abs, rel);
|
|
80
|
+
const dir = path.join(folder, 'structure');
|
|
81
|
+
return { abs, rel, folder, dir, relDir: `${rel}/structure`, events: path.join(dir, 'structure.jsonl'), versions: path.join(dir, 'versions'), pending: path.join(dir, 'pending'),
|
|
82
|
+
dry: path.join(dir, 'dry'), checks: path.join(dir, 'checks'), checksPending: path.join(dir, 'checks', 'pending'), brief: path.join(dir, 'brief.md'), briefRel: `${rel}/structure/brief.md` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── the one writer and the fold ───────────────────────────────────────────────────────────────────────────
|
|
86
|
+
function append(root, id, line, { actor, now } = {}) {
|
|
87
|
+
if (!isObj(line) || !EVENTS.includes(line.ev)) throw new Error(`report-structure: unknown event ${JSON.stringify(line && line.ev)}`);
|
|
88
|
+
const p = paths(root, id);
|
|
89
|
+
const out = { ...line, at: iso(now), actor: isObj(actor) ? { by: str(actor.by), via: str(actor.via) } : { ...PMOS_ACTOR } };
|
|
90
|
+
fs.mkdirSync(p.dir, { recursive: true });
|
|
91
|
+
fs.appendFileSync(p.events, JSON.stringify(out) + '\n');
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
function readEvents(root, id) {
|
|
95
|
+
let text = '';
|
|
96
|
+
try { text = fs.readFileSync(paths(root, id).events, 'utf8'); } catch { return []; }
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const raw of text.split('\n')) {
|
|
99
|
+
if (!raw.trim()) continue;
|
|
100
|
+
try { const l = JSON.parse(raw); if (isObj(l) && EVENTS.includes(l.ev)) out.push(l); } catch { /* a torn line is skipped */ }
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
const listOf = (v) => (Array.isArray(v) ? v : []);
|
|
105
|
+
|
|
106
|
+
function fold(events) {
|
|
107
|
+
const st = { description: null, log: [], versions: new Map(), current: null, saved: null, dry: null, errors: [], turns: 0 };
|
|
108
|
+
for (const l of events) {
|
|
109
|
+
if (l.ev === 'describe') {
|
|
110
|
+
st.description = str(l.text) || st.description;
|
|
111
|
+
st.log.push({ who: 'pm', text: str(l.text), at: l.at });
|
|
112
|
+
} else if (l.ev === 'turn') {
|
|
113
|
+
st.turns++;
|
|
114
|
+
st.log.push({ who: 'pm', text: str(l.text), at: l.at });
|
|
115
|
+
} else if (l.ev === 'version') {
|
|
116
|
+
st.versions.set(l.n, { n: l.n, at: l.at, turn: str(l.turn), from: l.from || 'turn' });
|
|
117
|
+
st.current = l.n;
|
|
118
|
+
st.log.push({ who: 'pmos', text: str(l.answer) || `Made S${l.n}.`, at: l.at, n: l.n, questions: listOf(l.questions), options: listOf(l.options) });
|
|
119
|
+
} else if (l.ev === 'accept') {
|
|
120
|
+
st.saved = { n: l.n, at: l.at };
|
|
121
|
+
st.log.push({ who: 'pm', text: `Saved S${l.n} as the structure.`, at: l.at, n: l.n });
|
|
122
|
+
} else if (l.ev === 'revert') {
|
|
123
|
+
if (st.versions.has(l.n)) st.current = l.n;
|
|
124
|
+
st.log.push({ who: 'pm', text: `Back to S${l.n}. The next turn builds on it.`, at: l.at, n: l.n });
|
|
125
|
+
} else if (l.ev === 'source') {
|
|
126
|
+
st.log.push({ who: 'pm', text: `Added the source ${str(l.source && l.source.name) || str(l.source && l.source.id)}.`, at: l.at });
|
|
127
|
+
} else if (l.ev === 'remove') {
|
|
128
|
+
st.log.push({ who: 'pm', text: `Removed the ${str(l.kind)} ${str(l.ref)}.`, at: l.at });
|
|
129
|
+
} else if (l.ev === 'dry') {
|
|
130
|
+
st.dry = { run: str(l.run), n: l.n, at: l.at, ok: l.ok === true };
|
|
131
|
+
} else if (l.ev === 'error') {
|
|
132
|
+
st.errors.push({ text: str(l.text), at: l.at, mode: l.mode || null });
|
|
133
|
+
st.log.push({ who: 'pmos', text: str(l.text), at: l.at });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return st;
|
|
137
|
+
}
|
|
138
|
+
const stateOf = (root, id) => fold(readEvents(root, id));
|
|
139
|
+
const nextN = (st) => (st.versions.size ? Math.max(...st.versions.keys()) : 0) + 1;
|
|
140
|
+
|
|
141
|
+
// ── text safety ───────────────────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
const URL_RE = /\b(?:[a-z][a-z0-9+.-]*:\/\/|www\.)/i;
|
|
143
|
+
/** Why a text is refused, or null: not text, too long, or it holds a script, an on* handler, javascript:/vbscript:, or (unless allowUrl) an address. */
|
|
144
|
+
function badText(v, where, { allowUrl = false, max = LIMITS.text } = {}) {
|
|
145
|
+
if (typeof v !== 'string') return `${where} must be text`;
|
|
146
|
+
if (v.length > max) return `${where} is at most ${max} characters`;
|
|
147
|
+
if (/<\s*\/?\s*script/i.test(v)) return `${where} must not hold a script`;
|
|
148
|
+
if (/\b(?:javascript|vbscript)\s*:/i.test(v) || /data:\s*text\/html/i.test(v)) return `${where} must not hold a javascript: address`;
|
|
149
|
+
if (/<[^>]*\bon[a-z]+\s*=/i.test(v) || /\bon[a-z]+\s*=\s*["'`]/i.test(v)) return `${where} must not hold an on* handler`;
|
|
150
|
+
if (!allowUrl && URL_RE.test(v)) return `${where} must not hold an address (an address belongs only in the ref of a link or a Chrome source)`;
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
/** Read an optional text field. → { v } | { error } */
|
|
154
|
+
function textField(o, key, where, opts) {
|
|
155
|
+
const raw = o[key];
|
|
156
|
+
if (raw === undefined || raw === null) return { v: '' };
|
|
157
|
+
const why = badText(raw, `${where}.${key}`, opts);
|
|
158
|
+
return why ? { error: why } : { v: raw.trim() };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── the plan ──────────────────────────────────────────────────────────────────────────────────────────────
|
|
162
|
+
const groupOf = (kind) => (PMOS_KINDS.includes(kind) ? 'pmos' : 'outside');
|
|
163
|
+
const defaultState = (kind) => (kind === 'past-report' ? 'later' : PMOS_KINDS.includes(kind) ? 'reached' : kind === 'chrome' || kind === 'computer' ? 'needs-you' : kind === 'person' || kind === 'upload' ? 'later' : 'not-connected');
|
|
164
|
+
|
|
165
|
+
function cleanSource(s, where) {
|
|
166
|
+
if (!isObj(s)) return refuse(422, `${where} must be an object`);
|
|
167
|
+
if (!KINDS.includes(s.kind)) return refuse(422, `${where}.kind ${JSON.stringify(str(s.kind)).slice(0, 40)} is not a source kind (${KINDS.join(', ')})`);
|
|
168
|
+
if (!SLUG_RE.test(str(s.id))) return refuse(422, `${where}.id must be a short lowercase slug`);
|
|
169
|
+
const out = { id: s.id, kind: s.kind };
|
|
170
|
+
for (const k of ['name', 'via', 'use']) { const r = textField(s, k, where); if (r.error) return refuse(422, r.error); out[k] = r.v; }
|
|
171
|
+
if (!out.name) return refuse(422, `${where}.name is required`);
|
|
172
|
+
const urlKind = s.kind === 'link' || s.kind === 'chrome';
|
|
173
|
+
const ref = textField(s, 'ref', where, { allowUrl: urlKind });
|
|
174
|
+
if (ref.error) return refuse(422, ref.error);
|
|
175
|
+
if (urlKind && ref.v) {
|
|
176
|
+
let u = null; try { u = new URL(ref.v); } catch { u = null; }
|
|
177
|
+
if (!u || (u.protocol !== 'https:' && u.protocol !== 'http:')) return refuse(422, `${where}.ref must be a full https:// or http:// address`);
|
|
178
|
+
}
|
|
179
|
+
out.ref = ref.v;
|
|
180
|
+
if (s.state === undefined || s.state === null) out.state = defaultState(s.kind);
|
|
181
|
+
else if (!STATES.includes(s.state)) return refuse(422, `${where}.state ${JSON.stringify(str(s.state)).slice(0, 40)} is not one of ${STATES.join(', ')}`);
|
|
182
|
+
else out.state = s.state;
|
|
183
|
+
out.group = groupOf(s.kind);
|
|
184
|
+
return { ok: true, source: out };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function cleanChart(c, where, sourceIds, known) {
|
|
188
|
+
if (!isObj(c)) return refuse(422, `${where} must be an object`);
|
|
189
|
+
if (!SLUG_RE.test(str(c.id))) return refuse(422, `${where}.id must be a short lowercase slug`);
|
|
190
|
+
const out = { id: c.id, chart: null };
|
|
191
|
+
if (c.chart !== undefined && c.chart !== null) {
|
|
192
|
+
if (!CS.ID_RE.test(str(c.chart))) return refuse(422, `${where}.chart must be a chart id or null`);
|
|
193
|
+
if (known && !known.has(c.chart)) return refuse(422, `${where}.chart ${c.chart} is not in the chart store`);
|
|
194
|
+
out.chart = c.chart;
|
|
195
|
+
}
|
|
196
|
+
for (const k of ['name', 'question', 'from', 'how', 'compare', 'flag', 'query', 'why']) { const r = textField(c, k, where); if (r.error) return refuse(422, r.error); out[k] = r.v; }
|
|
197
|
+
const src = str(c.source);
|
|
198
|
+
if (!src || !sourceIds.has(src)) return refuse(422, `${where}.source ${JSON.stringify(src).slice(0, 40)} is not a source of the plan`);
|
|
199
|
+
out.source = src;
|
|
200
|
+
if (!out.chart) for (const k of ['name', 'question', 'from', 'how']) if (!out[k]) return refuse(422, `${where}.${k} is required for a new chart`);
|
|
201
|
+
if (out.chart && !out.name) return refuse(422, `${where}.name is required`);
|
|
202
|
+
return { ok: true, chart: out };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The plan of section 3, cleaned. `charts` (a Set of chart store ids) lets a From Charts chart be checked; without it any well-formed id passes. */
|
|
206
|
+
function validatePlan(plan, { charts } = {}) {
|
|
207
|
+
if (!isObj(plan)) return refuse(422, 'the plan must be a JSON object');
|
|
208
|
+
const out = {};
|
|
209
|
+
// purpose
|
|
210
|
+
const pu = plan.purpose;
|
|
211
|
+
if (!isObj(pu)) return refuse(422, 'purpose is required');
|
|
212
|
+
out.purpose = {};
|
|
213
|
+
for (const k of ['readers', 'decision', 'scope', 'cadence']) { const r = textField(pu, k, 'purpose'); if (r.error) return refuse(422, r.error); out.purpose[k] = r.v; }
|
|
214
|
+
const qs = pu.questions === undefined ? [] : pu.questions;
|
|
215
|
+
if (!Array.isArray(qs)) return refuse(422, 'purpose.questions must be a list');
|
|
216
|
+
if (qs.length > LIMITS.questions) return refuse(422, `purpose.questions holds at most ${LIMITS.questions}`);
|
|
217
|
+
out.purpose.questions = [];
|
|
218
|
+
for (const [i, q] of qs.entries()) { const why = badText(q, `purpose.questions[${i}]`); if (why) return refuse(422, why); out.purpose.questions.push(q.trim()); }
|
|
219
|
+
// sources
|
|
220
|
+
if (!Array.isArray(plan.sources)) return refuse(422, 'sources must be a list');
|
|
221
|
+
if (plan.sources.length > LIMITS.sources) return refuse(422, `a plan holds at most ${LIMITS.sources} sources (this one has ${plan.sources.length})`);
|
|
222
|
+
out.sources = [];
|
|
223
|
+
const sourceIds = new Set();
|
|
224
|
+
for (const [i, s] of plan.sources.entries()) {
|
|
225
|
+
const r = cleanSource(s, `sources[${i}]`); if (!r.ok) return r;
|
|
226
|
+
if (sourceIds.has(r.source.id)) return refuse(422, `sources[${i}].id ${r.source.id} is used twice`);
|
|
227
|
+
sourceIds.add(r.source.id); out.sources.push(r.source);
|
|
228
|
+
}
|
|
229
|
+
// charts
|
|
230
|
+
if (!Array.isArray(plan.charts)) return refuse(422, 'charts must be a list');
|
|
231
|
+
if (plan.charts.length > LIMITS.charts) return refuse(422, `a plan holds at most ${LIMITS.charts} charts (this one has ${plan.charts.length})`);
|
|
232
|
+
out.charts = [];
|
|
233
|
+
const chartIds = new Set();
|
|
234
|
+
for (const [i, c] of plan.charts.entries()) {
|
|
235
|
+
const r = cleanChart(c, `charts[${i}]`, sourceIds, charts); if (!r.ok) return r;
|
|
236
|
+
if (chartIds.has(r.chart.id)) return refuse(422, `charts[${i}].id ${r.chart.id} is used twice`);
|
|
237
|
+
chartIds.add(r.chart.id); out.charts.push(r.chart);
|
|
238
|
+
}
|
|
239
|
+
// outline
|
|
240
|
+
if (!Array.isArray(plan.outline)) return refuse(422, 'outline must be a list');
|
|
241
|
+
if (plan.outline.length > LIMITS.sections) return refuse(422, `an outline holds at most ${LIMITS.sections} sections (this one has ${plan.outline.length})`);
|
|
242
|
+
const ids = plan.outline.map((o) => (isObj(o) ? o.id : null));
|
|
243
|
+
const n = ids.length;
|
|
244
|
+
if (n < 3 || ids[0] !== 'headline' || ids[n - 2] !== 'needs' || ids[n - 1] !== 'sources') {
|
|
245
|
+
return refuse(422, 'the outline must start with the fixed section headline and end with the fixed sections needs and sources');
|
|
246
|
+
}
|
|
247
|
+
out.outline = [];
|
|
248
|
+
const secIds = new Set();
|
|
249
|
+
for (const [i, o] of plan.outline.entries()) {
|
|
250
|
+
const where = `outline[${i}]`;
|
|
251
|
+
if (!isObj(o)) return refuse(422, `${where} must be an object`);
|
|
252
|
+
if (!SLUG_RE.test(str(o.id))) return refuse(422, `${where}.id must be a short lowercase slug`);
|
|
253
|
+
if (secIds.has(o.id)) return refuse(422, `${where}.id ${o.id} is used twice`);
|
|
254
|
+
secIds.add(o.id);
|
|
255
|
+
const fixed = FIXED.includes(o.id);
|
|
256
|
+
if (o.fixed === true && !fixed) return refuse(422, `${where}: only headline, needs and sources are fixed sections`);
|
|
257
|
+
const sec = { id: o.id, fixed };
|
|
258
|
+
for (const k of ['title', 'notes', 'show']) { const r = textField(o, k, where); if (r.error) return refuse(422, r.error); sec[k] = r.v; }
|
|
259
|
+
if (!sec.title) return refuse(422, `${where}.title is required`);
|
|
260
|
+
const cs = o.charts === undefined ? [] : o.charts;
|
|
261
|
+
if (!Array.isArray(cs)) return refuse(422, `${where}.charts must be a list of chart ids`);
|
|
262
|
+
for (const c of cs) if (typeof c !== 'string' || !chartIds.has(c)) return refuse(422, `${where}.charts names ${JSON.stringify(str(c)).slice(0, 40)}, which is not a chart of the plan`);
|
|
263
|
+
sec.charts = [...new Set(cs)];
|
|
264
|
+
out.outline.push({ id: sec.id, title: sec.title, fixed: sec.fixed, charts: sec.charts, notes: sec.notes, show: sec.show });
|
|
265
|
+
}
|
|
266
|
+
// options
|
|
267
|
+
const opts = plan.options === undefined || plan.options === null ? [] : plan.options;
|
|
268
|
+
const o2 = cleanOptions(opts, 'options'); if (!o2.ok) return o2;
|
|
269
|
+
out.options = o2.options;
|
|
270
|
+
return { ok: true, plan: out };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function cleanOptions(list, where) {
|
|
274
|
+
if (!Array.isArray(list)) return refuse(422, `${where} must be a list`);
|
|
275
|
+
if (list.length > LIMITS.options) return refuse(422, `${where} holds at most ${LIMITS.options} structures (this has ${list.length})`);
|
|
276
|
+
const out = [];
|
|
277
|
+
for (const [i, o] of list.entries()) {
|
|
278
|
+
const w = `${where}[${i}]`;
|
|
279
|
+
if (!isObj(o)) return refuse(422, `${w} must be an object`);
|
|
280
|
+
const c = {};
|
|
281
|
+
for (const k of ['name', 'good', 'misses']) { const r = textField(o, k, w); if (r.error) return refuse(422, r.error); c[k] = r.v; }
|
|
282
|
+
if (!c.name) return refuse(422, `${w}.name is required`);
|
|
283
|
+
const secs = o.sections === undefined ? [] : o.sections;
|
|
284
|
+
if (!Array.isArray(secs) || secs.length > LIMITS.sections) return refuse(422, `${w}.sections must be a list of at most ${LIMITS.sections}`);
|
|
285
|
+
c.sections = [];
|
|
286
|
+
for (const [j, s] of secs.entries()) { const why = badText(s, `${w}.sections[${j}]`); if (why) return refuse(422, why); c.sections.push(s.trim()); }
|
|
287
|
+
c.recommended = o.recommended === true;
|
|
288
|
+
out.push({ name: c.name, sections: c.sections, good: c.good, misses: c.misses, recommended: c.recommended });
|
|
289
|
+
}
|
|
290
|
+
return { ok: true, options: out };
|
|
291
|
+
}
|
|
292
|
+
/** The questions a job prints with its answer: [{ q, chips }]. A bad list is dropped, not an error. */
|
|
293
|
+
function cleanQuestions(list) {
|
|
294
|
+
if (!Array.isArray(list)) return [];
|
|
295
|
+
const out = [];
|
|
296
|
+
for (const q of list.slice(0, LIMITS.questions)) {
|
|
297
|
+
if (!isObj(q) || badText(q.q, 'q')) continue;
|
|
298
|
+
const chips = listOf(q.chips).filter((c) => typeof c === 'string' && c.trim() && !badText(c, 'chip', { max: 80 })).slice(0, LIMITS.chips).map((c) => c.trim());
|
|
299
|
+
out.push({ q: q.q.trim(), chips });
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** One source the PM adds: the section 3 source with no `state`. The id is made from the name when none is given. */
|
|
305
|
+
function validateSource(source, { taken = [] } = {}) {
|
|
306
|
+
if (!isObj(source)) return refuse(400, 'source must be an object');
|
|
307
|
+
if (source.state !== undefined && source.state !== null) return refuse(400, 'a source you add has no state: PMOS sets it');
|
|
308
|
+
const s = { ...source };
|
|
309
|
+
if (!s.id) {
|
|
310
|
+
const base = CS.slugOf(str(s.name) || str(s.kind));
|
|
311
|
+
let id = base; let k = 2;
|
|
312
|
+
while (taken.includes(id)) id = `${base.slice(0, 36)}-${k++}`;
|
|
313
|
+
s.id = id;
|
|
314
|
+
}
|
|
315
|
+
const r = cleanSource(s, 'source');
|
|
316
|
+
return r.ok ? r : refuse(400, r.error);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The next plan with the source added (not validated: the caller validates). */
|
|
320
|
+
function addSourceTo(plan, source) {
|
|
321
|
+
if (listOf(plan.sources).some((s) => s.id === source.id)) return refuse(400, `the plan already has a source ${source.id}`);
|
|
322
|
+
if (listOf(plan.sources).length >= LIMITS.sources) return refuse(400, `a plan holds at most ${LIMITS.sources} sources`);
|
|
323
|
+
return { ok: true, plan: { ...plan, sources: [...plan.sources, source] } };
|
|
324
|
+
}
|
|
325
|
+
/** The next plan without a chart, a source or a section. A fixed section is refused; a source that charts read from is refused. */
|
|
326
|
+
function removeFrom(plan, kind, ref) {
|
|
327
|
+
if (kind === 'chart') {
|
|
328
|
+
if (!plan.charts.some((c) => c.id === ref)) return refuse(404, `no chart ${ref} in this version`);
|
|
329
|
+
return { ok: true, plan: { ...plan, charts: plan.charts.filter((c) => c.id !== ref), outline: plan.outline.map((o) => ({ ...o, charts: o.charts.filter((c) => c !== ref) })) } };
|
|
330
|
+
}
|
|
331
|
+
if (kind === 'source') {
|
|
332
|
+
if (!plan.sources.some((s) => s.id === ref)) return refuse(404, `no source ${ref} in this version`);
|
|
333
|
+
const users = plan.charts.filter((c) => c.source === ref).map((c) => c.name || c.id);
|
|
334
|
+
if (users.length) return refuse(400, `${users.length === 1 ? 'a chart reads' : users.length + ' charts read'} from this source (${users.slice(0, 3).join(', ')}). Remove ${users.length === 1 ? 'it' : 'them'} first`);
|
|
335
|
+
return { ok: true, plan: { ...plan, sources: plan.sources.filter((s) => s.id !== ref) } };
|
|
336
|
+
}
|
|
337
|
+
if (kind === 'section') {
|
|
338
|
+
const sec = plan.outline.find((o) => o.id === ref);
|
|
339
|
+
if (!sec) return refuse(404, `no section ${ref} in this version`);
|
|
340
|
+
if (sec.fixed || FIXED.includes(ref)) return refuse(400, `${sec.title} is in every report and cannot be removed`);
|
|
341
|
+
return { ok: true, plan: { ...plan, outline: plan.outline.filter((o) => o.id !== ref) } };
|
|
342
|
+
}
|
|
343
|
+
return refuse(400, 'kind must be chart, source or section');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── dry run and checks ────────────────────────────────────────────────────────────────────────────────────
|
|
347
|
+
function validateDry(dry, plan) {
|
|
348
|
+
if (!isObj(dry)) return refuse(422, 'the dry-run result must be a JSON object');
|
|
349
|
+
const chartIds = new Set(listOf(plan && plan.charts).map((c) => c.id));
|
|
350
|
+
const sourceIds = new Set(listOf(plan && plan.sources).map((s) => s.id));
|
|
351
|
+
const out = {};
|
|
352
|
+
if (!Number.isInteger(dry.n) || dry.n < 1) return refuse(422, 'n must be the version number');
|
|
353
|
+
out.n = dry.n;
|
|
354
|
+
const per = badText(dry.period, 'period', { max: 40 });
|
|
355
|
+
if (per || !dry.period.trim()) return refuse(422, per || 'period is required');
|
|
356
|
+
out.period = dry.period.trim();
|
|
357
|
+
const hl = badText(dry.headline, 'headline');
|
|
358
|
+
if (hl) return refuse(422, hl);
|
|
359
|
+
out.headline = dry.headline.trim();
|
|
360
|
+
const values = dry.values === undefined ? [] : dry.values;
|
|
361
|
+
if (!Array.isArray(values) || values.length > LIMITS.charts) return refuse(422, `values is a list of at most ${LIMITS.charts}`);
|
|
362
|
+
out.values = [];
|
|
363
|
+
for (const [i, v] of values.entries()) {
|
|
364
|
+
const w = `values[${i}]`;
|
|
365
|
+
if (!isObj(v)) return refuse(422, `${w} must be an object`);
|
|
366
|
+
if (!chartIds.has(str(v.chart))) return refuse(422, `${w}.chart ${JSON.stringify(str(v.chart)).slice(0, 40)} is not a chart of the plan`);
|
|
367
|
+
const c = { chart: v.chart };
|
|
368
|
+
for (const k of ['value', 'was', 'delta', 'note']) { const r = textField(v, k, w); if (r.error) return refuse(422, r.error); c[k] = r.v; }
|
|
369
|
+
if (v.flag !== undefined && typeof v.flag !== 'boolean') return refuse(422, `${w}.flag must be true or false`);
|
|
370
|
+
c.flag = v.flag === true;
|
|
371
|
+
out.values.push({ chart: c.chart, value: c.value, was: c.was, delta: c.delta, flag: c.flag, note: c.note });
|
|
372
|
+
}
|
|
373
|
+
const missing = dry.missing === undefined ? [] : dry.missing;
|
|
374
|
+
if (!Array.isArray(missing) || missing.length > LIMITS.sources) return refuse(422, `missing is a list of at most ${LIMITS.sources}`);
|
|
375
|
+
out.missing = [];
|
|
376
|
+
for (const [i, m] of missing.entries()) {
|
|
377
|
+
const w = `missing[${i}]`;
|
|
378
|
+
if (!isObj(m)) return refuse(422, `${w} must be an object`);
|
|
379
|
+
if (!sourceIds.has(str(m.source))) return refuse(422, `${w}.source ${JSON.stringify(str(m.source)).slice(0, 40)} is not a source of the plan`);
|
|
380
|
+
const r = textField(m, 'reason', w); if (r.error) return refuse(422, r.error);
|
|
381
|
+
out.missing.push({ source: m.source, reason: r.v });
|
|
382
|
+
}
|
|
383
|
+
const options = dry.options === undefined ? [] : dry.options;
|
|
384
|
+
if (!Array.isArray(options) || options.length > LIMITS.options) return refuse(422, `options is a list of at most ${LIMITS.options}`);
|
|
385
|
+
out.options = [];
|
|
386
|
+
for (const [i, o] of options.entries()) { const why = badText(o, `options[${i}]`); if (why) return refuse(422, why); out.options.push(o.trim()); }
|
|
387
|
+
return { ok: true, dry: out };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The steps a check session wrote. Steps it left out are 'waiting'; an unknown step or state is refused. → { ok, steps, pass } */
|
|
391
|
+
function validateCheck(check, kind) {
|
|
392
|
+
const table = CHECK_STEPS[kind];
|
|
393
|
+
if (!table) return refuse(400, 'only a Chrome or a computer source has a check');
|
|
394
|
+
if (!isObj(check)) return refuse(422, 'the check result must be a JSON object');
|
|
395
|
+
const given = new Map();
|
|
396
|
+
for (const [i, s] of listOf(check.steps).entries()) {
|
|
397
|
+
if (!isObj(s) || !table.some(([id]) => id === s.id)) return refuse(422, `steps[${i}].id ${JSON.stringify(str(s && s.id)).slice(0, 40)} is not a step of a ${kind} check`);
|
|
398
|
+
if (!CHECK_STATES.includes(s.state)) return refuse(422, `steps[${i}].state must be works, needs-you or waiting`);
|
|
399
|
+
const r = textField(s, 'reason', `steps[${i}]`, { max: 300 }); if (r.error) return refuse(422, r.error);
|
|
400
|
+
given.set(s.id, { state: s.state, reason: r.v });
|
|
401
|
+
}
|
|
402
|
+
const steps = table.map(([id, label]) => { const g = given.get(id) || { state: 'waiting', reason: '' }; return { id, label, state: g.state, reason: g.reason }; });
|
|
403
|
+
return { ok: true, steps, pass: steps.every((s) => s.state === 'works') };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function writeDry(root, id, run, dry) { writeAtomic(path.join(paths(root, id).dry, `${run}.json`), JSON.stringify(dry, null, 2) + '\n'); }
|
|
407
|
+
function readDry(root, id, run) { return RUN_RE.test(String(run)) ? readJson(path.join(paths(root, id).dry, `${run}.json`)) : null; }
|
|
408
|
+
function writeCheck(root, id, check) {
|
|
409
|
+
if (!SLUG_RE.test(String(check.source))) throw new Error('report-structure: bad source id');
|
|
410
|
+
writeAtomic(path.join(paths(root, id).checks, `${check.source}.json`), JSON.stringify(check, null, 2) + '\n');
|
|
411
|
+
}
|
|
412
|
+
function readCheck(root, id, source) { return SLUG_RE.test(String(source)) ? readJson(path.join(paths(root, id).checks, `${source}.json`)) : null; }
|
|
413
|
+
function readChecks(root, id) {
|
|
414
|
+
const out = {};
|
|
415
|
+
let names = [];
|
|
416
|
+
try { names = fs.readdirSync(paths(root, id).checks); } catch { return out; }
|
|
417
|
+
for (const f of names) { const m = /^([a-z][a-z0-9-]{0,39})\.json$/.exec(f); if (m) { const c = readJson(path.join(paths(root, id).checks, f)); if (c) out[m[1]] = c; } }
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ── versions ──────────────────────────────────────────────────────────────────────────────────────────────
|
|
422
|
+
function readVersion(root, id, n) { return Number.isInteger(n) && n > 0 ? readJson(path.join(paths(root, id).versions, `S${n}.json`)) : null; }
|
|
423
|
+
function rewriteVersion(root, id, n, plan) { writeAtomic(path.join(paths(root, id).versions, `S${n}.json`), JSON.stringify(plan, null, 2) + '\n'); return plan; }
|
|
424
|
+
|
|
425
|
+
/** Validate, write S<n>.json, then the `before` lines and the version line. → { ok, n, plan } | refusal */
|
|
426
|
+
function landVersion(root, id, plan, { turn, answer, questions, options, from, before, actor, now, charts } = {}) {
|
|
427
|
+
const v = validatePlan(plan, { charts });
|
|
428
|
+
if (!v.ok) return v;
|
|
429
|
+
const st = stateOf(root, id);
|
|
430
|
+
const n = nextN(st);
|
|
431
|
+
writeAtomic(path.join(paths(root, id).versions, `S${n}.json`), JSON.stringify(v.plan, null, 2) + '\n');
|
|
432
|
+
for (const b of listOf(before)) append(root, id, { ...b, n }, { actor, now });
|
|
433
|
+
const line = { ev: 'version', n, turn: clip(turn, LIMITS.turn), answer: clip(answer, LIMITS.turn), from: from || 'turn' };
|
|
434
|
+
const qs = cleanQuestions(questions); if (qs.length) line.questions = qs;
|
|
435
|
+
let opts = null;
|
|
436
|
+
if (Array.isArray(options)) { const o = cleanOptions(options, 'options'); if (o.ok) opts = o.options; }
|
|
437
|
+
if (!opts || !opts.length) opts = v.plan.options;
|
|
438
|
+
if (opts.length) line.options = opts;
|
|
439
|
+
append(root, id, line, { actor, now });
|
|
440
|
+
return { ok: true, n, plan: v.plan };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ── the GET answer ────────────────────────────────────────────────────────────────────────────────────────
|
|
444
|
+
/** Chrome and computer sources show what their last check found: a passing check is Reached, a failing one Needs you. */
|
|
445
|
+
function overlay(plan, checks) {
|
|
446
|
+
if (!plan) return null;
|
|
447
|
+
return { ...plan, sources: plan.sources.map((s) => {
|
|
448
|
+
const c = checks[s.id];
|
|
449
|
+
if (!c || (s.kind !== 'chrome' && s.kind !== 'computer') || c.running) return s;
|
|
450
|
+
return { ...s, state: c.ok ? 'reached' : 'needs-you' };
|
|
451
|
+
}) };
|
|
452
|
+
}
|
|
453
|
+
function structureAnswer(root, id, { running } = {}) {
|
|
454
|
+
const st = stateOf(root, id);
|
|
455
|
+
const checks = readChecks(root, id);
|
|
456
|
+
const versions = [...st.versions.values()].sort((a, b) => a.n - b.n).map((v) => ({ n: v.n, at: v.at, turn: v.turn }));
|
|
457
|
+
return {
|
|
458
|
+
running: !!running, job: running ? { since: running.since, turn: running.turn, kind: running.kind } : null,
|
|
459
|
+
current: st.current, saved: st.saved ? st.saved.n : null, versions, log: st.log,
|
|
460
|
+
plan: st.current != null ? overlay(readVersion(root, id, st.current), checks) : null,
|
|
461
|
+
dry: st.dry, checks, errors: st.errors.map((e) => ({ text: e.text, at: e.at })),
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
/** What the setups GET carries for one report: { saved, at, charts, sections } of the accepted version, else null. */
|
|
465
|
+
function savedOf(root, id) {
|
|
466
|
+
let st; try { st = stateOf(root, id); } catch { return null; }
|
|
467
|
+
if (!st.saved) return null;
|
|
468
|
+
const plan = readVersion(root, id, st.saved.n);
|
|
469
|
+
if (!plan) return null;
|
|
470
|
+
return { saved: st.saved.n, at: st.saved.at, charts: plan.charts.length, sections: plan.outline.length };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── brief.md ──────────────────────────────────────────────────────────────────────────────────────────────
|
|
474
|
+
const yq = (s) => JSON.stringify(String(s == null ? '' : s));
|
|
475
|
+
function writeBrief(root, id, setup, { now } = {}) {
|
|
476
|
+
const st = stateOf(root, id);
|
|
477
|
+
if (!st.saved) return refuse(409, 'no structure is saved yet');
|
|
478
|
+
const plan = readVersion(root, id, st.saved.n);
|
|
479
|
+
if (!plan) return refuse(409, `S${st.saved.n} is not on disk`);
|
|
480
|
+
const name = (setup && setup.name) || id;
|
|
481
|
+
const day = iso(now).slice(0, 10);
|
|
482
|
+
const L = ['---', 'type: Reference', `title: ${yq('Report structure: ' + name)}`, `timestamp: ${day}`, '---', '', `# Report structure: ${name}`, '',
|
|
483
|
+
`The saved structure of the ${name} report. Each cycle is drafted from it: the outline's sections in order, each chart worked out from its source by the logic below. It is a brief, not a template.`, '',
|
|
484
|
+
`- Saved version: S${st.saved.n} (structure/versions/S${st.saved.n}.json), saved ${st.saved.at.slice(0, 10)}`, '', '## What it answers', ''];
|
|
485
|
+
const pu = plan.purpose;
|
|
486
|
+
L.push(`- Readers: ${pu.readers || 'not said'}`, `- Decision: ${pu.decision || 'not said'}`, `- Scope: ${pu.scope || 'not said'}`, `- Cadence: ${pu.cadence || 'not said'}`);
|
|
487
|
+
if (pu.questions.length) { L.push('', 'Questions it answers:', ''); for (const q of pu.questions) L.push(`- ${q}`); }
|
|
488
|
+
const srcName = (sid) => (plan.sources.find((s) => s.id === sid) || { name: sid }).name;
|
|
489
|
+
for (const [h, g] of [['Sources from PMOS', 'pmos'], ['Sources outside PMOS', 'outside']]) {
|
|
490
|
+
L.push('', `## ${h}`, '');
|
|
491
|
+
const list = plan.sources.filter((s) => s.group === g);
|
|
492
|
+
if (!list.length) L.push('None.');
|
|
493
|
+
for (const s of list) L.push(`- ${s.name} (${s.kind}${s.via ? ', through ' + s.via : ''}${s.ref ? ', ' + s.ref : ''}): ${s.use || 'no use stated'}`);
|
|
494
|
+
}
|
|
495
|
+
L.push('', '## Charts', '');
|
|
496
|
+
if (!plan.charts.length) L.push('None.');
|
|
497
|
+
for (const c of plan.charts) {
|
|
498
|
+
L.push(`### ${c.name || c.id}${c.chart ? ` (chart ${c.chart})` : ''}`, '', `- Question: ${c.question || 'not said'}`, `- Source: ${srcName(c.source)}`, `- Read: ${c.from || 'not said'}`,
|
|
499
|
+
`- Worked out: ${c.how || 'not said'}`, `- Compared with: ${c.compare || 'not said'}`, `- Flagged when: ${c.flag || 'not said'}`, '');
|
|
500
|
+
}
|
|
501
|
+
L.push('## Outline', '');
|
|
502
|
+
plan.outline.forEach((o, i) => {
|
|
503
|
+
L.push(`${i + 1}. ${o.title}${o.fixed ? ' (in every report)' : ''}`);
|
|
504
|
+
if (o.charts.length) L.push(` - Charts: ${o.charts.map((c) => (plan.charts.find((x) => x.id === c) || { name: c }).name || c).join(', ')}`);
|
|
505
|
+
if (o.notes) L.push(` - PMOS notes: ${o.notes}`);
|
|
506
|
+
if (o.show) L.push(` - Shown as: ${o.show}`);
|
|
507
|
+
});
|
|
508
|
+
L.push('');
|
|
509
|
+
writeAtomic(paths(root, id).brief, L.join('\n'));
|
|
510
|
+
return { ok: true, path: paths(root, id).briefRel };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
module.exports = { EVENTS, KINDS, PMOS_KINDS, OUTSIDE_KINDS, STATES, FIXED, LIMITS, CHECK_STEPS, CHECK_STATES, CADENCE_RE, SLUG_RE, RUN_RE,
|
|
514
|
+
paths, append, readEvents, fold, stateOf, nextN, badText, validatePlan, validateSource, validateDry, validateCheck, cleanQuestions, cleanOptions, addSourceTo, removeFrom,
|
|
515
|
+
landVersion, readVersion, rewriteVersion, writeDry, readDry, writeCheck, readCheck, readChecks, writeBrief, structureAnswer, savedOf, overlay, defaultState, newRun };
|
|
@@ -49,7 +49,8 @@ const MODULES = Object.freeze([
|
|
|
49
49
|
'routes-forecast', // console3 B5: forecast, critical path, time machine and capacity (read-only)
|
|
50
50
|
'routes-calendar-views', // console3 C2: Day/Week/Month/Quarter/Year cells, Gantt and the strategy lens (read-only)
|
|
51
51
|
'routes-briefing', // console3 C1: the day-part Briefing cards from the signal engine (read-only)
|
|
52
|
-
'routes-brief-page', // console3 Briefing as the brief: Today agenda
|
|
52
|
+
'routes-brief-page', // console3 Briefing as the brief: Today agenda, Needs you and Mentions (read-only; writes go through routes-pm-items)
|
|
53
|
+
'routes-mentions', // console3 Briefing Mentions: the PM's read marks (page press only)
|
|
53
54
|
'routes-phone-day', // phone companion: the agent's finished drafts, the evening recap and tomorrow at a glance (read-only)
|
|
54
55
|
'routes-report-facts', // console3 D: GET /api/report-families/facts (read-only, real data only)
|
|
55
56
|
'routes-records', // console3 E1a: ideas, hypotheses, decisions, KR metrics, bets (GET list; page-press add/edit/reading)
|
|
@@ -74,6 +75,7 @@ const MODULES = Object.freeze([
|
|
|
74
75
|
'routes-report-setups', // report-workspace: recurring reports, the Ask box, the finder, the report documents and their export (report-setups.jsonl)
|
|
75
76
|
'routes-core-docs', // core-documents: the core documents of each level, Draft it (a core-doc run), Issue it and Drop (core-docs.jsonl)
|
|
76
77
|
'routes-report-design', // report-design-studio: the design studio (describe, samples, turns, versions, This is it), cycle renders and decisions, speaker notes and pins
|
|
78
|
+
'routes-report-structure', // report-structure: the structure step (turns, versions, Use this structure, sources and their checks, dry run) and the chart store (GET /api/charts)
|
|
77
79
|
'routes-refresh', // data-refresh-settings: Settings › Data refresh: what refreshes, when, the refresh log, Refresh now
|
|
78
80
|
]);
|
|
79
81
|
const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
|
|
@@ -17,8 +17,15 @@
|
|
|
17
17
|
* evidence is { text, event_id } when the calendar holds a meeting with the person, else null: the page asks the PM to
|
|
18
18
|
* confirm it and never marks the item done itself.
|
|
19
19
|
* A decision row: { key, kind: 'decision', headline, tag, card }.
|
|
20
|
+
* mentions the items of today's morning brief (<runtime>/briefs/<date>.json) that say `mention: true`: someone named the PM in Slack, Jira,
|
|
21
|
+
* Confluence or mail (initiative c3-brief-summary-mentions). Newest first: { id, source, who, where, text, at, reply, link, initiative,
|
|
22
|
+
* in_needs, need_key, read }. text is the item's quote, else its ask, else its title. at is the time of the author's last message in the item's thread.
|
|
23
|
+
* reply is the item's reply_needed. in_needs: a worth-asking item, which Needs you shows as its row need_key ("ask:<id>"). A mention whose ledger copy
|
|
24
|
+
* (brief:<id>) is done or dropped is left out. read: the PM marked it read (<runtime>/mentions/read.jsonl). A field the item lacks is absent.
|
|
25
|
+
* mentions is null when there is no brief for today.
|
|
20
26
|
* Item fields read defensively (a missing field is absent): kind, defer, person, evidence, workPlacement(item, today) when lib/pm-items.js
|
|
21
|
-
* has it. A deferred item ('Not yet') is left out until its defer date or time. Writes go through the existing pm-items routes
|
|
27
|
+
* has it. A deferred item ('Not yet') is left out until its defer date or time. Writes go through the existing pm-items routes; a mention's
|
|
28
|
+
* read mark goes through POST /api/mentions/read (routes-mentions.js). */
|
|
22
29
|
const path = require('path');
|
|
23
30
|
const pmItems = require('./pm-items.js');
|
|
24
31
|
const { repoScript } = require('./repo-script.js');
|
|
@@ -129,13 +136,53 @@ function needsOf(items, events, cards, now, deps = {}) {
|
|
|
129
136
|
return [...late, ...dueToday, ...offline, ...decisions];
|
|
130
137
|
}
|
|
131
138
|
|
|
139
|
+
const MENTION_ID = /^[0-9a-f]{12}$/;
|
|
140
|
+
const httpsUrl = (v) => { const t = String(v || ''); try { return new URL(t).protocol === 'https:' && t.length <= 500 ? t : null; } catch { return null; } };
|
|
141
|
+
|
|
142
|
+
/** The read marks: the last line for each id wins. */
|
|
143
|
+
function readMarks(lines) {
|
|
144
|
+
const m = new Map();
|
|
145
|
+
for (const l of lines || []) if (l && MENTION_ID.test(String(l.id || '')) && typeof l.read === 'boolean') m.set(l.id, l.read);
|
|
146
|
+
return m;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The mentions of today's brief. itemLines: the PM's ledger (pm-items items.jsonl lines); reads: readMarks(). Pure. */
|
|
150
|
+
function mentionsOf(brief, itemLines, reads, todayIso) {
|
|
151
|
+
if (!brief || brief.date !== todayIso) return null;
|
|
152
|
+
const folded = pmItems.fold(itemLines || []);
|
|
153
|
+
const done = new Set(folded.filter((i) => i.done).map((i) => i.id));
|
|
154
|
+
const live = new Set(folded.map((i) => i.id));
|
|
155
|
+
const dropped = new Set((itemLines || []).filter((l) => l && l.id && !live.has(l.id)).map((l) => l.id));
|
|
156
|
+
const seen = new Set(); const out = [];
|
|
157
|
+
for (const [list, ask] of [[brief.priorities, false], [brief.worth_asking, true]]) {
|
|
158
|
+
for (const x of Array.isArray(list) ? list : []) {
|
|
159
|
+
if (!x || !x.mention || !MENTION_ID.test(String(x.id || '')) || seen.has(x.id)) continue;
|
|
160
|
+
seen.add(x.id);
|
|
161
|
+
const key = 'brief:' + x.id;
|
|
162
|
+
if (done.has(key) || dropped.has(key)) continue;
|
|
163
|
+
const thread = Array.isArray(x.thread) ? x.thread : [];
|
|
164
|
+
const mine = thread.filter((m) => m && m.at && !Number.isNaN(Date.parse(m.at)) && (!x.who || m.who === x.who));
|
|
165
|
+
const row = { id: x.id, source: x.source, text: clip(x.quote || x.ask || x.title, 280), reply: x.reply_needed === true, in_needs: ask || !!x.worth_asking, read: reads && reads.get(x.id) === true };
|
|
166
|
+
if (x.who) row.who = clip(x.who, 120);
|
|
167
|
+
if (x.where) row.where = clip(x.where, 120);
|
|
168
|
+
row.at = mine.length ? new Date(mine[mine.length - 1].at).toISOString() : null;
|
|
169
|
+
const link = httpsUrl(x.link); if (link) row.link = link;
|
|
170
|
+
if (x.initiative_id) row.initiative = x.initiative_id;
|
|
171
|
+
if (row.in_needs) row.need_key = 'ask:' + x.id; // the Needs you row it is (briefing-real.js askNeed)
|
|
172
|
+
out.push(row);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return out.sort((a, b) => (b.at ? Date.parse(b.at) : 0) - (a.at ? Date.parse(a.at) : 0));
|
|
176
|
+
}
|
|
177
|
+
|
|
132
178
|
/** The pure builder. deps: { events, items (folded), cards, notes } so a test needs no repository. */
|
|
133
179
|
function build(query, deps = {}) {
|
|
134
180
|
const q = query || {};
|
|
135
181
|
let now = q.now ? new Date(q.now) : (deps.now ? new Date(deps.now) : new Date());
|
|
136
182
|
if (Number.isNaN(now.getTime())) now = new Date();
|
|
137
183
|
const events = deps.events || []; const items = deps.items || [];
|
|
138
|
-
const body = { ok: true, date: localDate(now), today: todayAgenda(events, items, now, deps.notes || {}), needs: needsOf(items, events, deps.cards || [], now, deps)
|
|
184
|
+
const body = { ok: true, date: localDate(now), today: todayAgenda(events, items, now, deps.notes || {}), needs: needsOf(items, events, deps.cards || [], now, deps),
|
|
185
|
+
mentions: mentionsOf(deps.brief || null, deps.itemLines || [], readMarks(deps.readLines), localDate(now)) };
|
|
139
186
|
if (deps.decisionsError) body.decisions_error = deps.decisionsError;
|
|
140
187
|
return { status: 200, body };
|
|
141
188
|
}
|
|
@@ -164,7 +211,8 @@ function firstName(cwd) {
|
|
|
164
211
|
|
|
165
212
|
module.exports = {
|
|
166
213
|
prefixes: ['/api/brief-page'],
|
|
167
|
-
|
|
214
|
+
MENTION_ID,
|
|
215
|
+
build, todayAgenda, needsOf, evidenceFor, deferred, firstName, notesAdapter, mentionsOf, readMarks,
|
|
168
216
|
handle: async (req, res, url, ctx) => {
|
|
169
217
|
if (url.pathname !== '/api/brief-page') return false;
|
|
170
218
|
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
@@ -174,7 +222,10 @@ module.exports = {
|
|
|
174
222
|
try {
|
|
175
223
|
const dir = ctx.runtimeDir;
|
|
176
224
|
const events = dir ? require('./calendar-events.js').readAll(dir, { now: now.getTime() }) : [];
|
|
177
|
-
const
|
|
225
|
+
const itemLines = dir ? pmItems.readLines(path.join(dir, 'pm-items', 'items.jsonl')) : [];
|
|
226
|
+
const items = pmItems.fold(itemLines);
|
|
227
|
+
let brief = null; try { brief = dir ? JSON.parse(require('fs').readFileSync(path.join(dir, 'briefs', localDate(now) + '.json'), 'utf8')) : null; } catch { brief = null; }
|
|
228
|
+
const readLines = dir ? pmItems.readLines(path.join(dir, 'mentions', 'read.jsonl')) : [];
|
|
178
229
|
// The decisions come from the signal engine, whose repository read is slow. The page already holds them (GET /api/briefing feeds its deck), so the
|
|
179
230
|
// read is made only when the caller asks (?decisions=1); the items and the agenda never wait for it.
|
|
180
231
|
let cards = []; let decisionsError = null;
|
|
@@ -185,7 +236,7 @@ module.exports = {
|
|
|
185
236
|
const facts = await require('./facts-async.js').readFactsAsync(root, { today: iso });
|
|
186
237
|
cards = require(repoScript('signals/index.js')).present(facts, { lens: 'Unblock', zoom: 'week', today: iso }).cards || [];
|
|
187
238
|
} catch (e) { decisionsError = 'the decisions could not be read: ' + (e && e.message ? e.message : String(e)); }
|
|
188
|
-
const out = build({ now: now.toISOString() }, { events, items, cards, decisionsError, notes: dir ? notesAdapter(dir) : {} });
|
|
239
|
+
const out = build({ now: now.toISOString() }, { events, items, itemLines, brief, readLines, cards, decisionsError, notes: dir ? notesAdapter(dir) : {} });
|
|
189
240
|
if (out.body && out.body.ok) out.body.me = firstName(path.resolve(__dirname, '..', '..'));
|
|
190
241
|
ctx.json(res, out.body, out.status);
|
|
191
242
|
} catch (e) {
|