create-pmos 0.179.0 → 0.181.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-driver-test.js +6 -1
- package/workspace/c3-report-setup-test.js +190 -0
- package/workspace/c3-reports-test.js +30 -97
- package/workspace/guide/document-sheet.md +14 -0
- package/workspace/guide/index.md +3 -2
- package/workspace/guide/initiative-driver.md +2 -2
- package/workspace/guide/reports-dashboards.md +17 -28
- package/workspace/guide/reports-page.md +10 -5
- package/workspace/guide/reports-set-up.md +56 -0
- package/workspace/guide/reports.md +13 -1
- package/workspace/guide/settings.md +11 -2
- package/workspace/lib/doc-sync.js +28 -6
- package/workspace/lib/docs-cli.js +7 -11
- package/workspace/lib/four-panel.js +1 -1
- package/workspace/lib/headless.js +21 -2
- package/workspace/lib/report-checks.js +103 -0
- package/workspace/lib/report-cli.js +89 -4
- package/workspace/lib/report-export.js +347 -0
- package/workspace/lib/report-finder.js +156 -0
- package/workspace/lib/report-needs.js +1 -1
- package/workspace/lib/report-setups.js +743 -0
- package/workspace/lib/route-modules.js +1 -0
- package/workspace/lib/routes-c3-reports.js +35 -17
- package/workspace/lib/routes-doc-health.js +15 -1
- package/workspace/lib/routes-report-setups.js +368 -0
- package/workspace/lib/schedule-logic.js +5 -2
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-demo-rep2.js +15 -0
- package/workspace/public/pm/c3-demo-rs.js +281 -0
- package/workspace/public/pm/c3-demo.js +1 -1
- package/workspace/public/pm/c3-doc-formats.js +152 -0
- package/workspace/public/pm/c3-doc-sheet.js +115 -25
- package/workspace/public/pm/c3-ini-ui.js +15 -1
- package/workspace/public/pm/c3-initiatives.js +3 -6
- package/workspace/public/pm/c3-quiet.js +2 -2
- package/workspace/public/pm/c3-report-print.css +20 -0
- package/workspace/public/pm/c3-report-setup.css +62 -0
- package/workspace/public/pm/c3-report-setup.js +260 -0
- package/workspace/public/pm/c3-report-store.js +208 -0
- package/workspace/public/pm/c3-reports.css +4 -0
- package/workspace/public/pm/c3-reports.js +47 -181
- package/workspace/public/pm/c3-settings.js +4 -3
- package/workspace/public/pm/c3-shell.js +15 -31
- package/workspace/server.js +66 -5
- package/workspace/skills/doc-sync.skill +17 -8
- package/workspace/skills/report-find.skill +75 -0
- package/workspace/skills/report-setup.skill +67 -0
- package/workspace/test-files.json +10 -0
- package/workspace/test-lists/report-workspace-ui.json +5 -0
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* report-setups.js — the recurring reports a PM has set up, the proposals PMOS makes for them and the reports it finds
|
|
3
|
+
* (initiative report-workspace, PRD sections 2 to 4). THE ONE WRITER and THE ONE FOLD of <state>/report-setups.jsonl.
|
|
4
|
+
*
|
|
5
|
+
* LEDGER · EVENTS · OUTPUTS · CADENCES · FINDER_SOURCES · LIMITS
|
|
6
|
+
* ledgerFile(root) · stateDir(root) <state> is `pmos/state` in a kit repository, `planning` in PMOS-self (report-draft.js stateRel)
|
|
7
|
+
* append(file, line, { now }) → the line written. THE SINGLE WRITER. `v` and `at` are set here.
|
|
8
|
+
* readLines(file) · fold(lines) → the current state (see FOLD)
|
|
9
|
+
* checkProposal(input) → { ok, proposal } | { ok: false, error } the shape of a proposal, as an agent files it
|
|
10
|
+
* patternOf(name, pattern) · slugOf(name) the stable key of a found report; the id a name makes
|
|
11
|
+
* scheduleLabel(schedule) · scheduleOf(setup) the plain phrase, and the schedule-logic.js form of a setup's schedule
|
|
12
|
+
* firstSlot(schedule, now) · initialLastRun(schedule, now) · nextDraftAt(schedule, lastRun, now) the bi-weekly parity (below)
|
|
13
|
+
* definitionText(setup, { product, now }) · writeDefinition(root, setup, { now }) the definition a setup writes
|
|
14
|
+
* setUp(root, input, opts) · change(root, id, patch, opts) · pause / resume / remove(root, id, opts) the PM's presses
|
|
15
|
+
* scheduleFor(setup) · registerSchedule(store, setup, opts) · unregisterSchedule(store, setup) the draft-only schedule
|
|
16
|
+
* askStart / askChange / askCancel / askEnd / fileProposal(file, …) the Ask box
|
|
17
|
+
* fileFinding / dismissFinding / setFinder / findStart / findEnd the weekly finder
|
|
18
|
+
* migrate(root, { runtimeDir, wsId, isHome, titleOf, now }) the dashboard schedules, once
|
|
19
|
+
* dashboardKeys(root) the dashboard keys the old schedule tick leaves alone (migrated, or held by a setup)
|
|
20
|
+
* writeDocument(root, setup, slot, markdown, { now }) <state>/reports/<id>/<slot>.md for a dashboard-sourced setup
|
|
21
|
+
*
|
|
22
|
+
* THE LINES. Every line is { v: 1, ev, …, at, by? }. Who wrote a line is the call path (lib/actor.js), never an argument:
|
|
23
|
+
* setup { id, name, purpose, audience, source: 'work'|'dashboard:<key>', output, schedule, sources[], sections[], learned_from, fixes[],
|
|
24
|
+
* product, ws, last_run, def_sha, from? } the PM pressed Set up. `from` is { ask } or { finding }
|
|
25
|
+
* change { id, …the fields that changed } the PM edited a recurring report
|
|
26
|
+
* pause · resume · remove { id }
|
|
27
|
+
* drafted { id, slot, path } a dashboard-sourced setup made its document (this is its last run)
|
|
28
|
+
* ask-start { ask, text, ws } the PM typed a request in the Ask box
|
|
29
|
+
* proposal { ask, proposal } the report-setup run filed ONE proposal (a second one revises it)
|
|
30
|
+
* proposal-change { ask, text } the PM asked for a correction; a second run revises the same proposal
|
|
31
|
+
* proposal-cancel { ask }
|
|
32
|
+
* ask-end { ask, ok, error? } the run ended (a failed run says why)
|
|
33
|
+
* find-start · find-end { run, ok?, error?, found? } one weekly finder run
|
|
34
|
+
* finding { id, run, pattern, name, why, evidence[], confidence, proposal }
|
|
35
|
+
* finding-dismiss { id?, pattern } "Not a report": the pattern is not proposed again
|
|
36
|
+
* finder { cadence?, sources? } the finder settings: weekly | off, and the four sources
|
|
37
|
+
* migrated { keys[], count } the one-time move of the dashboard schedules
|
|
38
|
+
*
|
|
39
|
+
* FOLD. { setups: Map id → setup, asks: Map, findings: Map, dismissed: { patterns, names }, finder: { cadence, sources }, finds: [], migrated }.
|
|
40
|
+
* A later line wins field by field; a `remove` drops the setup; a line that does not parse, or names an unknown event, is skipped.
|
|
41
|
+
*
|
|
42
|
+
* THE BI-WEEKLY PARITY. lib/schedule-logic.js fires a bi-weekly schedule on the first matching weekday slot that is at least 12.5 days after
|
|
43
|
+
* lastRun. A schedule added on a Monday would therefore draft on the Wednesday two weeks out, whatever week the session is in. With an
|
|
44
|
+
* `anchor` (the date of the next session) the first draft is the draft day on or just before the anchor, and lastRun is set 14 days before
|
|
45
|
+
* it: the slot a week before it is 7 days from lastRun, so it does not fire, and the first slot is exact.
|
|
46
|
+
*
|
|
47
|
+
* NOTHING HERE SENDS ANYTHING. A setup writes a definition, registers a draft-only schedule (skill report-template, which drafts a file) and
|
|
48
|
+
* appends a line. The factory never writes here (D65): a proposal is data, and only a PM press sets a report up.
|
|
49
|
+
*/
|
|
50
|
+
const fs = require('fs');
|
|
51
|
+
const path = require('path');
|
|
52
|
+
const crypto = require('crypto');
|
|
53
|
+
const logic = require('./schedule-logic.js');
|
|
54
|
+
const reportDraft = require('./report-draft.js');
|
|
55
|
+
const docSync = require('./doc-sync.js');
|
|
56
|
+
|
|
57
|
+
const LEDGER = 'report-setups.jsonl';
|
|
58
|
+
const EVENTS = Object.freeze(['setup', 'change', 'pause', 'resume', 'remove', 'drafted', 'ask-start', 'proposal', 'proposal-change', 'proposal-cancel', 'ask-end',
|
|
59
|
+
'find-start', 'find-end', 'finding', 'finding-dismiss', 'finder', 'migrated']);
|
|
60
|
+
const OUTPUTS = Object.freeze(['google-slides', 'google-doc', 'google-sheets', 'confluence', 'pdf']);
|
|
61
|
+
const CADENCES = Object.freeze(['daily', 'weekly', 'bi-weekly', 'monthly']);
|
|
62
|
+
const DAYS = Object.freeze(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']);
|
|
63
|
+
const FINDER_CADENCES = Object.freeze(['weekly', 'off']);
|
|
64
|
+
const FINDER_SOURCES = Object.freeze(['calendar', 'slack', 'mail', 'drive']);
|
|
65
|
+
const FINDER_DEFAULTS = Object.freeze({ calendar: true, slack: true, mail: false, drive: true });
|
|
66
|
+
const EVIDENCE_KINDS = Object.freeze(['mail', 'calendar', 'slack', 'drive', 'confluence']);
|
|
67
|
+
const CONFIDENCE = Object.freeze(['likely', 'not-sure']);
|
|
68
|
+
const LIMITS = Object.freeze({ name: 80, purpose: 400, audience: 120, text: 1000, sources: 12, sections: 12, fixes: 8, found: 8, evidence: 6, findings: 5, asks: 20 });
|
|
69
|
+
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
70
|
+
const ASK_RE = /^ask-[0-9a-f]{10}$/;
|
|
71
|
+
const FINDING_RE = /^rf-[0-9a-f]{10}$/;
|
|
72
|
+
const RUN_RE = /^frun-[0-9a-f]{10}$/;
|
|
73
|
+
const PATTERN_RE = /^[a-z0-9][a-z0-9:._ -]{1,119}$/;
|
|
74
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
75
|
+
const TIME_RE = /^([01]?\d|2[0-3]):[0-5]\d$/;
|
|
76
|
+
const FRAME = reportDraft.FRAME;
|
|
77
|
+
const DASH_RE = /^dashboard:(portfolio|(board|initiative):[A-Za-z0-9._-]{1,80})$/;
|
|
78
|
+
const DAY_MS = 86400000;
|
|
79
|
+
|
|
80
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
81
|
+
const oneLine = (v, max) => typeof v === 'string' && v.trim().length >= 1 && v.trim().length <= max && !/[\r\n\x00-\x08\x0b-\x1f]/.test(v);
|
|
82
|
+
const newId = (prefix, bytes = 5) => `${prefix}-${crypto.randomBytes(bytes).toString('hex')}`;
|
|
83
|
+
const refuse = (status, error) => ({ ok: false, status, error });
|
|
84
|
+
const iso = (now) => new Date(now == null ? Date.now() : now).toISOString();
|
|
85
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
86
|
+
const localDay = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
87
|
+
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
88
|
+
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
89
|
+
/** 21 October 2026, written in full (a report never says "next week"). */
|
|
90
|
+
const longDate = (d) => `${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
|
|
91
|
+
|
|
92
|
+
// ── the one writer, and the read ─────────────────────────────────────────────────────────────────────
|
|
93
|
+
/** <root>/<state>: `pmos/state` in a kit repository, `planning` in PMOS-self. */
|
|
94
|
+
function stateDir(root) { const abs = path.resolve(String(root)); return path.join(abs, reportDraft.stateRel(abs)); }
|
|
95
|
+
function ledgerFile(root) { return path.join(stateDir(root), LEDGER); }
|
|
96
|
+
|
|
97
|
+
/** THE SINGLE WRITER of report-setups.jsonl. `v` and `at` are set here; a caller never chooses them. */
|
|
98
|
+
function append(file, line, { now, by } = {}) {
|
|
99
|
+
if (!file) throw new Error('report-setups: no ledger file');
|
|
100
|
+
if (!isObj(line) || !EVENTS.includes(line.ev)) throw new Error(`report-setups: unknown event ${JSON.stringify(line && line.ev)}`);
|
|
101
|
+
const out = { ...line, v: 1, at: iso(now), ...(by ? { by } : {}) };
|
|
102
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
103
|
+
fs.appendFileSync(file, JSON.stringify(out) + '\n');
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
function readLines(file) {
|
|
107
|
+
let text = '';
|
|
108
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const raw of text.split('\n')) {
|
|
111
|
+
if (!raw.trim()) continue;
|
|
112
|
+
try { const l = JSON.parse(raw); if (isObj(l) && EVENTS.includes(l.ev)) out.push(l); } catch { /* a torn line is skipped */ }
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── names and keys ─────────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
/** The id a name makes: lower-case words joined by "-", starting with a letter, at most 40 characters. */
|
|
119
|
+
function slugOf(name) {
|
|
120
|
+
let s = String(name == null ? '' : name).normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
121
|
+
if (!s) s = 'report';
|
|
122
|
+
if (!/^[a-z]/.test(s)) s = 'r-' + s;
|
|
123
|
+
s = s.slice(0, 40).replace(/-+$/g, '');
|
|
124
|
+
return s || 'report';
|
|
125
|
+
}
|
|
126
|
+
/** The stable key of a found report: the agent's own `pattern` (lower-cased, spaces folded), else `name:<slug of the name>`. */
|
|
127
|
+
function patternOf(name, pattern) {
|
|
128
|
+
const p = String(pattern == null ? '' : pattern).trim().toLowerCase().replace(/\s+/g, ' ');
|
|
129
|
+
if (p && PATTERN_RE.test(p)) return p;
|
|
130
|
+
return 'name:' + slugOf(name);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── shapes ───────────────────────────────────────────────────────────────────────────────────────────
|
|
134
|
+
function checkSchedule(x) {
|
|
135
|
+
const s = isObj(x) ? x : {};
|
|
136
|
+
if (!CADENCES.includes(s.cadence)) return { ok: false, error: `schedule.cadence is one of ${CADENCES.join(', ')}` };
|
|
137
|
+
const out = { cadence: s.cadence, time: '09:00' };
|
|
138
|
+
if (s.time != null && s.time !== '') {
|
|
139
|
+
if (!TIME_RE.test(String(s.time))) return { ok: false, error: 'schedule.time is HH:MM, 24-hour' };
|
|
140
|
+
const [h, m] = String(s.time).split(':'); out.time = `${pad(+h)}:${m}`;
|
|
141
|
+
}
|
|
142
|
+
if (s.cadence === 'weekly' || s.cadence === 'bi-weekly') {
|
|
143
|
+
out.day = s.day == null || s.day === '' ? 'Mon' : s.day;
|
|
144
|
+
if (!DAYS.includes(out.day)) return { ok: false, error: `schedule.day is one of ${DAYS.join(', ')}` };
|
|
145
|
+
}
|
|
146
|
+
if (s.cadence === 'monthly') {
|
|
147
|
+
out.dayOfMonth = s.dayOfMonth == null || s.dayOfMonth === '' ? 1 : +s.dayOfMonth;
|
|
148
|
+
if (!(Number.isInteger(out.dayOfMonth) && out.dayOfMonth >= 1 && out.dayOfMonth <= 28)) return { ok: false, error: 'schedule.dayOfMonth is a whole number from 1 to 28' };
|
|
149
|
+
}
|
|
150
|
+
out.anchor = null;
|
|
151
|
+
if (s.anchor != null && s.anchor !== '') {
|
|
152
|
+
if (!DATE_RE.test(String(s.anchor)) || Number.isNaN(Date.parse(s.anchor + 'T00:00:00Z'))) return { ok: false, error: 'schedule.anchor is a date, YYYY-MM-DD (the next session)' };
|
|
153
|
+
out.anchor = String(s.anchor);
|
|
154
|
+
}
|
|
155
|
+
return { ok: true, schedule: out };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function listOf(v, max, item, what) {
|
|
159
|
+
if (v == null) return { ok: true, list: [] };
|
|
160
|
+
if (!Array.isArray(v)) return { ok: false, error: `${what} is a list` };
|
|
161
|
+
if (v.length > max) return { ok: false, error: `${what} holds at most ${max} items` };
|
|
162
|
+
const list = [];
|
|
163
|
+
for (const x of v) { if (!oneLine(x, item)) return { ok: false, error: `each of ${what} is one line of 1 to ${item} characters` }; list.push(x.trim()); }
|
|
164
|
+
return { ok: true, list };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The shape of a proposal as an agent files it and the PM edits it. */
|
|
168
|
+
function checkProposal(input) {
|
|
169
|
+
const x = isObj(input) ? input : {};
|
|
170
|
+
if (!oneLine(x.name, LIMITS.name)) return { ok: false, error: `name is one line of 1 to ${LIMITS.name} characters` };
|
|
171
|
+
if (!oneLine(x.purpose, LIMITS.purpose)) return { ok: false, error: `purpose is one line of 1 to ${LIMITS.purpose} characters` };
|
|
172
|
+
if (!oneLine(x.audience, LIMITS.audience)) return { ok: false, error: `audience is one line of 1 to ${LIMITS.audience} characters` };
|
|
173
|
+
const source = x.source == null || x.source === '' ? 'work' : x.source;
|
|
174
|
+
if (source !== 'work' && !DASH_RE.test(String(source))) return { ok: false, error: 'source is work, or dashboard:portfolio, dashboard:board:<id> or dashboard:initiative:<id>' };
|
|
175
|
+
if (!OUTPUTS.includes(x.output)) return { ok: false, error: `output is one of ${OUTPUTS.join(', ')}` };
|
|
176
|
+
const sch = checkSchedule(x.schedule);
|
|
177
|
+
if (!sch.ok) return sch;
|
|
178
|
+
const sources = listOf(x.sources, LIMITS.sources, 200, 'sources'); if (!sources.ok) return sources;
|
|
179
|
+
const sections = listOf(x.sections, LIMITS.sections, 60, 'sections'); if (!sections.ok) return sections;
|
|
180
|
+
const fixes = listOf(x.fixes, LIMITS.fixes, 200, 'fixes'); if (!fixes.ok) return fixes;
|
|
181
|
+
if (x.learned_from != null && x.learned_from !== '' && !oneLine(x.learned_from, LIMITS.text)) return { ok: false, error: `learned_from is one line of at most ${LIMITS.text} characters` };
|
|
182
|
+
const found = [];
|
|
183
|
+
if (x.found != null) {
|
|
184
|
+
if (!Array.isArray(x.found) || x.found.length > LIMITS.found) return { ok: false, error: `found is a list of at most ${LIMITS.found} items` };
|
|
185
|
+
for (const f of x.found) {
|
|
186
|
+
if (!isObj(f) || !oneLine(f.text, 240) || !EVIDENCE_KINDS.concat(['repo', 'note']).includes(f.kind) || (f.when != null && f.when !== '' && !oneLine(f.when, 60))) return { ok: false, error: 'each found item is { kind, text, when? }: kind mail, calendar, slack, drive, confluence, repo or note; text one line of up to 240 characters' };
|
|
187
|
+
found.push({ kind: f.kind, text: f.text.trim(), when: f.when ? f.when.trim() : null });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (x.product != null && x.product !== '' && !oneLine(x.product, 80)) return { ok: false, error: 'product is one line of at most 80 characters' };
|
|
191
|
+
const clean = (s) => String(s).replace(/["\\]/g, "'").trim();
|
|
192
|
+
return { ok: true, proposal: {
|
|
193
|
+
name: x.name.trim(), purpose: x.purpose.trim(), audience: clean(x.audience), source, output: x.output, schedule: sch.schedule,
|
|
194
|
+
sources: sources.list, sections: sections.list.length ? sections.list : FRAME.slice(), learned_from: x.learned_from ? x.learned_from.trim() : null,
|
|
195
|
+
fixes: fixes.list, found, ...(x.product ? { product: clean(x.product) } : {}),
|
|
196
|
+
} };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function checkEvidence(list) {
|
|
200
|
+
if (!Array.isArray(list) || !list.length || list.length > LIMITS.evidence) return { ok: false, error: `evidence is a list of 1 to ${LIMITS.evidence} items` };
|
|
201
|
+
const out = [];
|
|
202
|
+
for (const e of list) {
|
|
203
|
+
if (!isObj(e) || !EVIDENCE_KINDS.includes(e.kind) || !oneLine(e.text, 240)) return { ok: false, error: `each evidence item is { kind, text }: kind ${EVIDENCE_KINDS.join(', ')}; text one line of up to 240 characters` };
|
|
204
|
+
out.push({ kind: e.kind, text: e.text.trim() });
|
|
205
|
+
}
|
|
206
|
+
return { ok: true, evidence: out };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── the fold ─────────────────────────────────────────────────────────────────────────────────────────
|
|
210
|
+
const SETUP_FIELDS = ['name', 'purpose', 'audience', 'output', 'schedule', 'sources', 'sections', 'learned_from', 'fixes', 'product', 'def_sha'];
|
|
211
|
+
|
|
212
|
+
function fold(lines) {
|
|
213
|
+
const st = { setups: new Map(), asks: new Map(), findings: new Map(), dismissed: { patterns: new Set(), names: new Set() },
|
|
214
|
+
finder: { cadence: 'weekly', sources: { ...FINDER_DEFAULTS } }, finds: [], migrated: null };
|
|
215
|
+
for (const l of lines || []) {
|
|
216
|
+
if (!isObj(l)) continue;
|
|
217
|
+
switch (l.ev) {
|
|
218
|
+
case 'setup': {
|
|
219
|
+
if (!ID_RE.test(String(l.id || '')) || st.setups.has(l.id)) break;
|
|
220
|
+
st.setups.set(l.id, { id: l.id, name: l.name, purpose: l.purpose, audience: l.audience, source: l.source || 'work', output: l.output, schedule: l.schedule,
|
|
221
|
+
sources: l.sources || [], sections: l.sections || FRAME.slice(), learned_from: l.learned_from || null, fixes: l.fixes || [], product: l.product || null,
|
|
222
|
+
ws: l.ws || null, status: 'active', created_at: l.at, last_run: l.last_run || l.at, def_sha: l.def_sha || null, from: l.from || null, migrated: !!l.migrated, drafted: [] });
|
|
223
|
+
if (l.from && l.from.ask) st.asks.delete(l.from.ask);
|
|
224
|
+
if (l.from && l.from.finding && st.findings.has(l.from.finding)) st.findings.get(l.from.finding).state = 'set-up';
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case 'change': {
|
|
228
|
+
const s = st.setups.get(l.id); if (!s) break;
|
|
229
|
+
for (const k of SETUP_FIELDS) if (Object.prototype.hasOwnProperty.call(l, k)) s[k] = l[k];
|
|
230
|
+
s.changed_at = l.at;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
case 'pause': { const s = st.setups.get(l.id); if (s) s.status = 'paused'; break; }
|
|
234
|
+
case 'resume': { const s = st.setups.get(l.id); if (s) { s.status = 'active'; if (l.last_run) s.last_run = l.last_run; } break; }
|
|
235
|
+
case 'remove': st.setups.delete(l.id); break;
|
|
236
|
+
case 'drafted': { const s = st.setups.get(l.id); if (s) { s.last_run = l.at; s.drafted.push({ slot: l.slot, at: l.at, path: l.path || null }); } break; }
|
|
237
|
+
case 'ask-start':
|
|
238
|
+
if (ASK_RE.test(String(l.ask || ''))) st.asks.set(l.ask, { id: l.ask, text: l.text, ws: l.ws || null, state: 'running', changed: false, proposal: null, error: null, started: l.at, changes: [] });
|
|
239
|
+
break;
|
|
240
|
+
case 'proposal': { const a = st.asks.get(l.ask); if (a && isObj(l.proposal)) { a.proposal = l.proposal; a.state = 'ready'; a.error = null; } break; }
|
|
241
|
+
case 'proposal-change': { const a = st.asks.get(l.ask); if (a) { a.state = 'running'; a.changed = true; a.changes.push(l.text); a.error = null; } break; }
|
|
242
|
+
case 'proposal-cancel': st.asks.delete(l.ask); break;
|
|
243
|
+
case 'ask-end': {
|
|
244
|
+
const a = st.asks.get(l.ask); if (!a) break;
|
|
245
|
+
if (l.ok && a.proposal) { a.state = 'ready'; break; }
|
|
246
|
+
a.state = a.proposal ? 'ready' : 'failed';
|
|
247
|
+
a.error = l.ok ? 'The run ended without a proposal. Ask again.' : (l.error || 'The run failed.');
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
case 'find-start': st.finds.push({ run: l.run, start: l.at, end: null, ok: null, error: null, found: 0 }); break;
|
|
251
|
+
case 'find-end': { const f = st.finds.find((x) => x.run === l.run); if (f) { f.end = l.at; f.ok = !!l.ok; f.error = l.error || null; f.found = l.found || 0; } break; }
|
|
252
|
+
case 'finding':
|
|
253
|
+
if (FINDING_RE.test(String(l.id || '')) && !st.findings.has(l.id)) st.findings.set(l.id, { id: l.id, run: l.run || null, pattern: l.pattern, name: l.name, why: l.why, evidence: l.evidence || [], confidence: l.confidence, proposal: l.proposal, at: l.at, state: 'open' });
|
|
254
|
+
break;
|
|
255
|
+
case 'finding-dismiss': {
|
|
256
|
+
const f = l.id ? st.findings.get(l.id) : null;
|
|
257
|
+
if (f) f.state = 'dismissed';
|
|
258
|
+
const pat = l.pattern || (f && f.pattern);
|
|
259
|
+
if (pat) st.dismissed.patterns.add(pat);
|
|
260
|
+
const nm = f && f.name ? slugOf(f.name) : (l.name ? slugOf(l.name) : null);
|
|
261
|
+
if (nm) st.dismissed.names.add(nm);
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
case 'finder':
|
|
265
|
+
if (FINDER_CADENCES.includes(l.cadence)) st.finder.cadence = l.cadence;
|
|
266
|
+
if (isObj(l.sources)) for (const k of FINDER_SOURCES) if (typeof l.sources[k] === 'boolean') st.finder.sources[k] = l.sources[k];
|
|
267
|
+
break;
|
|
268
|
+
case 'migrated': st.migrated = { at: l.at, keys: Array.isArray(l.keys) ? l.keys : [], count: l.count || 0 }; break;
|
|
269
|
+
default: break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return st;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const stateOf = (root) => fold(readLines(ledgerFile(root)));
|
|
276
|
+
const openFindings = (st) => [...st.findings.values()].filter((f) => f.state === 'open' && !isDismissed(st, f.pattern, f.name));
|
|
277
|
+
function isDismissed(st, pattern, name) { return st.dismissed.patterns.has(pattern) || (name != null && st.dismissed.names.has(slugOf(name))); }
|
|
278
|
+
|
|
279
|
+
// ── schedules: the label, the form schedule-logic reads, and the bi-weekly parity ─────────────────────────────
|
|
280
|
+
const plural = { Mon: 'Mondays', Tue: 'Tuesdays', Wed: 'Wednesdays', Thu: 'Thursdays', Fri: 'Fridays', Sat: 'Saturdays', Sun: 'Sundays' };
|
|
281
|
+
function scheduleLabel(s) {
|
|
282
|
+
if (!isObj(s)) return '';
|
|
283
|
+
const t = s.time || '09:00';
|
|
284
|
+
if (s.cadence === 'daily') return `Daily at ${t}`;
|
|
285
|
+
if (s.cadence === 'weekly') return `Weekly, ${plural[s.day] || 'Mondays'} ${t}`;
|
|
286
|
+
if (s.cadence === 'bi-weekly') return `Every other ${(plural[s.day] || 'Mondays').replace(/s$/, '')} at ${t}`;
|
|
287
|
+
if (s.cadence === 'monthly') return `Monthly, day ${s.dayOfMonth || 1} at ${t}`;
|
|
288
|
+
return s.cadence || '';
|
|
289
|
+
}
|
|
290
|
+
/** A setup's schedule in the form lib/schedule-logic.js reads. */
|
|
291
|
+
const scheduleOf = (setup, lastRun) => ({ cadence: setup.schedule.cadence, day: setup.schedule.day, dayOfMonth: setup.schedule.dayOfMonth, time: setup.schedule.time, enabled: setup.status === 'active', lastRun: lastRun == null ? setup.last_run || null : lastRun });
|
|
292
|
+
const addDays = (d, n) => { const x = new Date(d); x.setDate(x.getDate() + n); return x; };
|
|
293
|
+
const localMidnight = (d) => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; };
|
|
294
|
+
function atTime(d, time) { const m = /^(\d{1,2}):(\d{2})$/.exec(String(time || '09:00')) || [0, 9, 0]; const x = new Date(d); x.setHours(+m[1], +m[2], 0, 0); return x; }
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The first draft slot of a bi-weekly schedule with an anchor: the draft day on or just before the first session on or after today.
|
|
298
|
+
* Sessions are the anchor and every 14th day after it. Anything else has no fixed first slot: null.
|
|
299
|
+
*/
|
|
300
|
+
function firstSlot(schedule, now = new Date()) {
|
|
301
|
+
if (!schedule || schedule.cadence !== 'bi-weekly' || !schedule.anchor || !DATE_RE.test(schedule.anchor)) return null;
|
|
302
|
+
const [y, m, d] = schedule.anchor.split('-').map(Number);
|
|
303
|
+
let session = new Date(y, m - 1, d);
|
|
304
|
+
const today = localMidnight(now);
|
|
305
|
+
let guard = 0;
|
|
306
|
+
while (session < today && guard++ < 400) session = addDays(session, 14);
|
|
307
|
+
const want = Math.max(0, DAYS.indexOf(schedule.day)) + 1; // Mon=1 … Sun=7 → JS getDay() 1…6, 0
|
|
308
|
+
const idx = want % 7;
|
|
309
|
+
let slot = atTime(session, schedule.time);
|
|
310
|
+
for (let i = 0; i < 7 && slot.getDay() !== idx; i++) slot = addDays(slot, -1);
|
|
311
|
+
return slot;
|
|
312
|
+
}
|
|
313
|
+
/** The lastRun to store so that the first draft is exact. With an anchor, 14 days before the first slot; otherwise now. */
|
|
314
|
+
function initialLastRun(schedule, now = new Date()) {
|
|
315
|
+
const slot = firstSlot(schedule, now);
|
|
316
|
+
return slot ? addDays(slot, -14).toISOString() : new Date(now).toISOString();
|
|
317
|
+
}
|
|
318
|
+
/** The next moment this schedule drafts, as a Date, or null. A slot already due (not drafted yet) answers that slot. */
|
|
319
|
+
function nextDraftAt(schedule, lastRun, now = new Date()) {
|
|
320
|
+
const base = { cadence: schedule.cadence, day: schedule.day, dayOfMonth: schedule.dayOfMonth, time: schedule.time, enabled: true, lastRun: lastRun || null };
|
|
321
|
+
if (logic.isDue(base, now)) return logic.lastSlot(base, now);
|
|
322
|
+
let cursor = new Date(now);
|
|
323
|
+
for (let i = 0; i < 80; i++) {
|
|
324
|
+
const s = logic.nextSlot(base, cursor);
|
|
325
|
+
if (!s) return null;
|
|
326
|
+
if (logic.isDue(base, new Date(s.getTime() + 60000))) return s;
|
|
327
|
+
cursor = s;
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── the definition ───────────────────────────────────────────────────────────────────────────────────
|
|
333
|
+
const SECTION_NOTES = Object.freeze({
|
|
334
|
+
headline: 'Headline. One or two sentences: the one thing the readers must know this cycle.',
|
|
335
|
+
timeline: 'Timeline. The dated items, written in full (14 October 2026, never "next week").',
|
|
336
|
+
initiatives: 'Initiatives. One block for each initiative. Each note covers Done, In progress, Next and the key open item.',
|
|
337
|
+
new: 'New since last report. What is new in the window, each with its date. The PM writes the release notes in `report.changelog`.',
|
|
338
|
+
decided: 'Decided this cycle. The decisions, marked Decided or Acknowledged.',
|
|
339
|
+
needs: 'Needs your call. Asks to the readers in `report.asks`: what, who, by when. Write "None this cycle" when there are none; never drop the section.',
|
|
340
|
+
sources: 'Sources reached and not reached.',
|
|
341
|
+
});
|
|
342
|
+
const OUTPUT_WORDS = Object.freeze({ 'google-slides': 'a Google Slides deck', 'google-doc': 'a Google Doc', 'google-sheets': 'a Google Sheets spreadsheet', confluence: 'a Confluence page', pdf: 'a PDF' });
|
|
343
|
+
|
|
344
|
+
function scheduleSentence(s, now) {
|
|
345
|
+
const first = firstSlot(s, now);
|
|
346
|
+
const base = scheduleLabel(s) + '.';
|
|
347
|
+
if (s.cadence === 'bi-weekly' && s.anchor && first) return `${base} The first draft is ${WEEKDAYS[first.getDay()]} ${longDate(first)}, for the session on ${longDate(new Date(s.anchor + 'T00:00:00'))}.`;
|
|
348
|
+
return base;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function definitionText(setup, { product, now = new Date() } = {}) {
|
|
352
|
+
const q = (v) => `"${String(v == null ? '' : v).replace(/["\\]/g, "'")}"`;
|
|
353
|
+
const prod = setup.product || product || null;
|
|
354
|
+
const date = localDay(new Date(now));
|
|
355
|
+
const list = (a, none) => (a && a.length ? a.map((x) => `- ${x}`).join('\n') : none);
|
|
356
|
+
const dash = setup.source !== 'work';
|
|
357
|
+
return [
|
|
358
|
+
'---', 'type: Reference', `title: ${q(setup.name + ' definition')}`,
|
|
359
|
+
`description: ${q(`The ${setup.name} report. pmos report draft reads the frontmatter; the report-template skill reads the prose.`)}`,
|
|
360
|
+
`timestamp: ${date}`, `cadence: ${setup.id}`, `audience: ${q(setup.audience)}`, ...(prod ? [`product: ${q(prod)}`] : []),
|
|
361
|
+
`sections: [${FRAME.join(', ')}]`, 'status: draft', `output: ${setup.output}`, `source: ${setup.source}`, '---', '',
|
|
362
|
+
`# ${setup.name} definition`, '',
|
|
363
|
+
`This file defines the ${setup.name} report. PMOS drafts it on a schedule; the PM issues it. Nothing is sent. Only the PM changes \`status\` to \`stable\`.`, '',
|
|
364
|
+
'## Purpose', '', setup.purpose, '',
|
|
365
|
+
'## Audience', '', `${setup.audience}. Needs your call holds asks to them only, each with who and by when.`, '',
|
|
366
|
+
'## Output', '', `The finished report is ${OUTPUT_WORDS[setup.output] || setup.output}. PMOS keeps the report as a document in this repository; a copy is made only when the PM presses Create the copy.`, '',
|
|
367
|
+
'## Schedule', '', scheduleSentence(setup.schedule, now), '',
|
|
368
|
+
'## Sources', '', list(setup.sources, dash ? `- The dashboard ${setup.source.slice('dashboard:'.length)}, as its report frame reads it.` : '- The local ledgers that `pmos report draft` reads.'), '',
|
|
369
|
+
'## Sections', '', 'The script writes the frame and every number. The agent writes only the notes.', '',
|
|
370
|
+
...FRAME.map((id, i) => `${i + 1}. ${SECTION_NOTES[id]}`), '',
|
|
371
|
+
'## Learned from earlier copies', '', setup.learned_from || 'None yet. Add what the earlier copies show after the first cycle.', '',
|
|
372
|
+
'## Fixes this report applies', '', list(setup.fixes, '- None yet.'), '',
|
|
373
|
+
'## Length and tone', '', 'One or two sentences for each block note. Plain words, short sentences. No numbers in notes: the script writes them.', '',
|
|
374
|
+
].join('\n');
|
|
375
|
+
}
|
|
376
|
+
const shaOf = (text) => crypto.createHash('sha256').update(String(text)).digest('hex').slice(0, 16);
|
|
377
|
+
|
|
378
|
+
function definitionFile(root, id) { return path.join(path.resolve(String(root)), reportDraft.definitionPath(root, id)); }
|
|
379
|
+
|
|
380
|
+
/** Write the definition. `keep: true` (a change) leaves a file the PM edited since the last write alone. → { ok, path, sha, kept? } */
|
|
381
|
+
function writeDefinition(root, setup, { now, keep, expectSha } = {}) {
|
|
382
|
+
const file = definitionFile(root, setup.id);
|
|
383
|
+
const text = definitionText(setup, { product: path.basename(path.resolve(String(root))), now: now == null ? new Date() : new Date(now) });
|
|
384
|
+
let cur = null;
|
|
385
|
+
try { cur = fs.readFileSync(file, 'utf8'); } catch { cur = null; }
|
|
386
|
+
if (cur != null && keep && expectSha && shaOf(cur) !== expectSha) return { ok: true, path: reportDraft.definitionPath(root, setup.id), sha: expectSha, kept: true };
|
|
387
|
+
if (cur != null && !keep) return refuse(409, `${reportDraft.definitionPath(root, setup.id)} exists already. Nothing was written`);
|
|
388
|
+
const tmp = `${file}.tmp-${crypto.randomBytes(6).toString('hex')}`;
|
|
389
|
+
try {
|
|
390
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
391
|
+
fs.writeFileSync(tmp, text, { flag: 'wx' });
|
|
392
|
+
fs.renameSync(tmp, file);
|
|
393
|
+
} catch (e) {
|
|
394
|
+
try { fs.unlinkSync(tmp); } catch { /* not made */ }
|
|
395
|
+
return refuse(500, 'could not write the definition; nothing was written: ' + String((e && e.message) || e).slice(0, 160));
|
|
396
|
+
}
|
|
397
|
+
return { ok: true, path: reportDraft.definitionPath(root, setup.id), sha: shaOf(text) };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Every definition in the repository: { id, path, audience, product, sections, errors, status, output, source, title }. Read-only. */
|
|
401
|
+
function definitionsOf(root) {
|
|
402
|
+
const abs = path.resolve(String(root));
|
|
403
|
+
const dir = path.join(abs, reportDraft.stateRel(abs), 'reports', 'definitions');
|
|
404
|
+
let names = [];
|
|
405
|
+
try { names = fs.readdirSync(dir).filter((f) => /\.md$/.test(f)).sort(); } catch { names = []; }
|
|
406
|
+
const out = [];
|
|
407
|
+
for (const f of names) {
|
|
408
|
+
const id = f.slice(0, -3);
|
|
409
|
+
if (!ID_RE.test(id)) continue;
|
|
410
|
+
const d = reportDraft.readDefinition(abs, id);
|
|
411
|
+
let fm = {};
|
|
412
|
+
try {
|
|
413
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
414
|
+
for (const line of (m ? m[1] : '').split(/\r?\n/)) { const kv = /^([A-Za-z_][\w-]*):\s*(.*?)\s*$/.exec(line); if (kv) fm[kv[1]] = kv[2].replace(/^["']|["']$/g, ''); }
|
|
415
|
+
} catch { fm = {}; }
|
|
416
|
+
out.push({ id, path: d.path, audience: d.audience, product: d.product, sections: d.sections, errors: d.errors, status: fm.status || null, output: fm.output || null, source: fm.source || null, title: fm.title || id });
|
|
417
|
+
}
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ── the draft-only schedule ──────────────────────────────────────────────────────────────────────────
|
|
422
|
+
/** The schedule store the server keeps (server.js readSchedules / writeSchedules), or a file-backed one for a test. */
|
|
423
|
+
function fileScheduleStore(file) {
|
|
424
|
+
return {
|
|
425
|
+
read() { try { const a = JSON.parse(fs.readFileSync(file, 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } },
|
|
426
|
+
write(list) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(list)); },
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
/** The registered schedule of a setup (skill report-template, args = the id), or null. */
|
|
430
|
+
function scheduleFor(store, setup, wsId) {
|
|
431
|
+
if (!store || typeof store.read !== 'function') return null;
|
|
432
|
+
return store.read().find((s) => s && s.skill === 'report-template' && s.args === setup.id && (!wsId || s.ws === wsId)) || null;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Register the draft-only schedule of a work-sourced setup: the report-template skill with the setup's id as its args. It only drafts a file.
|
|
436
|
+
* A dashboard-sourced setup has no entry here: the tick in lib/routes-report-setups.js drafts it from its own line.
|
|
437
|
+
*/
|
|
438
|
+
function registerSchedule(store, setup, { wsId, lastRun, now = new Date() } = {}) {
|
|
439
|
+
if (!store || typeof store.write !== 'function') return { ok: false, error: 'there is no schedule store' };
|
|
440
|
+
const norm = logic.normalizeSchedule({ name: `${setup.name} (draft)`, cadence: setup.schedule.cadence, day: setup.schedule.day, dayOfMonth: setup.schedule.dayOfMonth,
|
|
441
|
+
time: setup.schedule.time, skill: 'report-template', args: setup.id });
|
|
442
|
+
if (!norm.ok) return { ok: false, error: norm.error };
|
|
443
|
+
const list = store.read();
|
|
444
|
+
const have = list.find((s) => s && s.skill === 'report-template' && s.args === setup.id && (!wsId || s.ws === wsId));
|
|
445
|
+
if (have) return { ok: true, schedule: have, existed: true };
|
|
446
|
+
const sched = { id: 'sc-' + Date.now().toString(36) + crypto.randomBytes(2).toString('hex'), ws: wsId || null, ...norm.schedule, profile: null,
|
|
447
|
+
lastRun: lastRun || now.toISOString(), lastStatus: null, setup: setup.id };
|
|
448
|
+
sched.nextRun = nextDraftAt(sched, sched.lastRun, now) ? nextDraftAt(sched, sched.lastRun, now).toISOString() : null;
|
|
449
|
+
list.unshift(sched);
|
|
450
|
+
store.write(list);
|
|
451
|
+
return { ok: true, schedule: sched };
|
|
452
|
+
}
|
|
453
|
+
function updateSchedule(store, setup, wsId, patch) {
|
|
454
|
+
if (!store) return null;
|
|
455
|
+
const list = store.read();
|
|
456
|
+
const s = list.find((x) => x && x.skill === 'report-template' && x.args === setup.id && (!wsId || x.ws === wsId));
|
|
457
|
+
if (!s) return null;
|
|
458
|
+
Object.assign(s, patch);
|
|
459
|
+
store.write(list);
|
|
460
|
+
return s;
|
|
461
|
+
}
|
|
462
|
+
function unregisterSchedule(store, setup, wsId) {
|
|
463
|
+
if (!store) return false;
|
|
464
|
+
const list = store.read();
|
|
465
|
+
const keep = list.filter((x) => !(x && x.skill === 'report-template' && x.args === setup.id && (!wsId || x.ws === wsId)));
|
|
466
|
+
if (keep.length === list.length) return false;
|
|
467
|
+
store.write(keep);
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ── the presses ──────────────────────────────────────────────────────────────────────────────────────
|
|
472
|
+
function freeId(root, st, name) {
|
|
473
|
+
const base = slugOf(name);
|
|
474
|
+
const taken = (id) => st.setups.has(id) || fs.existsSync(definitionFile(root, id)) || fs.existsSync(path.join(stateDir(root), 'reports', id));
|
|
475
|
+
if (!taken(base)) return base;
|
|
476
|
+
for (let n = 2; n < 100; n++) { const id = `${base.slice(0, 36)}-${n}`; if (!taken(id)) return id; }
|
|
477
|
+
return `${base.slice(0, 30)}-${crypto.randomBytes(3).toString('hex')}`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Set up a recurring report. `input` is a proposal (checkProposal) plus `from: { ask } | { finding }`. Writes the definition, registers the
|
|
482
|
+
* draft-only schedule (work source), appends the `setup` line. → { ok, setup, first_draft: { slot, at } | null } | { ok: false, status, error }
|
|
483
|
+
* opts: { by, now, wsId, scheduleStore, lastRun (the old schedule's, for a migration), id }
|
|
484
|
+
*/
|
|
485
|
+
function setUp(root, input, opts = {}) {
|
|
486
|
+
const c = checkProposal(input);
|
|
487
|
+
if (!c.ok) return refuse(400, c.error);
|
|
488
|
+
const p = c.proposal;
|
|
489
|
+
const now = opts.now == null ? new Date() : new Date(opts.now);
|
|
490
|
+
const file = ledgerFile(root);
|
|
491
|
+
const st = fold(readLines(file));
|
|
492
|
+
const id = opts.id || freeId(root, st, p.name);
|
|
493
|
+
if (!ID_RE.test(id)) return refuse(400, 'the id is lower-case letters, digits and "-"');
|
|
494
|
+
if (st.setups.has(id)) return refuse(409, `${id} is set up already`);
|
|
495
|
+
const from = isObj(input.from) ? (input.from.ask ? { ask: String(input.from.ask) } : input.from.finding ? { finding: String(input.from.finding) } : null) : null;
|
|
496
|
+
const setup = { id, ...p, status: 'active', product: p.product || path.basename(path.resolve(String(root))) };
|
|
497
|
+
const lastRun = opts.lastRun || initialLastRun(p.schedule, now);
|
|
498
|
+
const def = writeDefinition(root, setup, { now });
|
|
499
|
+
if (!def.ok) return def;
|
|
500
|
+
if (p.source === 'work') {
|
|
501
|
+
const reg = registerSchedule(opts.scheduleStore, setup, { wsId: opts.wsId, lastRun, now });
|
|
502
|
+
if (!reg.ok) return refuse(500, reg.error);
|
|
503
|
+
}
|
|
504
|
+
append(file, { ev: 'setup', id, name: p.name, purpose: p.purpose, audience: p.audience, source: p.source, output: p.output, schedule: p.schedule, sources: p.sources,
|
|
505
|
+
sections: p.sections, learned_from: p.learned_from, fixes: p.fixes, product: setup.product, ws: opts.wsId || null, last_run: lastRun, def_sha: def.sha,
|
|
506
|
+
...(from ? { from } : {}), ...(opts.migrated ? { migrated: true } : {}) }, { now, by: opts.by });
|
|
507
|
+
const next = nextDraftAt(p.schedule, lastRun, now);
|
|
508
|
+
const folded = fold(readLines(file)).setups.get(id);
|
|
509
|
+
return { ok: true, setup: folded, definition: def.path, first_draft: next ? { slot: localDay(next), at: next.toISOString(), day: next.getDay() } : null };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** The PM edits a recurring report. `patch` is any of name, purpose, audience, output, schedule, sources, sections, learned_from, fixes. */
|
|
513
|
+
function change(root, id, patch, opts = {}) {
|
|
514
|
+
const file = ledgerFile(root);
|
|
515
|
+
const st = fold(readLines(file));
|
|
516
|
+
const cur = st.setups.get(id);
|
|
517
|
+
if (!cur) return refuse(404, `no recurring report ${String(id).slice(0, 40)}`);
|
|
518
|
+
const merged = checkProposal({ ...cur, ...(isObj(patch) ? patch : {}) });
|
|
519
|
+
if (!merged.ok) return refuse(400, merged.error);
|
|
520
|
+
const now = opts.now == null ? new Date() : new Date(opts.now);
|
|
521
|
+
const fields = {};
|
|
522
|
+
for (const k of ['name', 'purpose', 'audience', 'output', 'schedule', 'sources', 'sections', 'learned_from', 'fixes']) {
|
|
523
|
+
if (Object.prototype.hasOwnProperty.call(patch || {}, k) && JSON.stringify(merged.proposal[k]) !== JSON.stringify(cur[k])) fields[k] = merged.proposal[k];
|
|
524
|
+
}
|
|
525
|
+
if (!Object.keys(fields).length) return { ok: true, setup: cur, unchanged: true };
|
|
526
|
+
const next = { ...cur, ...fields };
|
|
527
|
+
const def = writeDefinition(root, next, { now, keep: true, expectSha: cur.def_sha });
|
|
528
|
+
if (!def.ok) return def;
|
|
529
|
+
if (fields.schedule && cur.source === 'work') {
|
|
530
|
+
const norm = logic.normalizeSchedule({ name: `${next.name} (draft)`, cadence: next.schedule.cadence, day: next.schedule.day, dayOfMonth: next.schedule.dayOfMonth, time: next.schedule.time, skill: 'report-template', args: id });
|
|
531
|
+
if (norm.ok) updateSchedule(opts.scheduleStore, next, opts.wsId, { cadence: norm.schedule.cadence, day: norm.schedule.day, dayOfMonth: norm.schedule.dayOfMonth, time: norm.schedule.time,
|
|
532
|
+
lastRun: initialLastRun(next.schedule, now), name: norm.schedule.name });
|
|
533
|
+
} else if (fields.name && cur.source === 'work') updateSchedule(opts.scheduleStore, next, opts.wsId, { name: `${next.name} (draft)` });
|
|
534
|
+
const lastRun = fields.schedule ? initialLastRun(next.schedule, now) : undefined;
|
|
535
|
+
append(file, { ev: 'change', id, ...fields, ...(lastRun ? { last_run: lastRun } : {}), def_sha: def.sha }, { now, by: opts.by });
|
|
536
|
+
return { ok: true, setup: fold(readLines(file)).setups.get(id), definition_kept: !!def.kept };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function setStatus(root, id, ev, opts = {}) {
|
|
540
|
+
const file = ledgerFile(root);
|
|
541
|
+
const cur = fold(readLines(file)).setups.get(id);
|
|
542
|
+
if (!cur) return refuse(404, `no recurring report ${String(id).slice(0, 40)}`);
|
|
543
|
+
if (ev === 'pause' && cur.status === 'paused') return { ok: true, setup: cur, unchanged: true };
|
|
544
|
+
if (ev === 'resume' && cur.status === 'active') return { ok: true, setup: cur, unchanged: true };
|
|
545
|
+
const now = opts.now == null ? new Date() : new Date(opts.now);
|
|
546
|
+
let lastRun;
|
|
547
|
+
if (ev === 'resume') lastRun = cur.schedule.cadence === 'bi-weekly' ? initialLastRun(cur.schedule, now) : now.toISOString();
|
|
548
|
+
if (cur.source === 'work') updateSchedule(opts.scheduleStore, cur, opts.wsId, ev === 'pause' ? { enabled: false } : { enabled: true, lastRun });
|
|
549
|
+
append(file, { ev, id, ...(lastRun ? { last_run: lastRun } : {}) }, { now, by: opts.by });
|
|
550
|
+
return { ok: true, setup: fold(readLines(file)).setups.get(id) };
|
|
551
|
+
}
|
|
552
|
+
const pause = (root, id, opts) => setStatus(root, id, 'pause', opts);
|
|
553
|
+
const resume = (root, id, opts) => setStatus(root, id, 'resume', opts);
|
|
554
|
+
function remove(root, id, opts = {}) {
|
|
555
|
+
const file = ledgerFile(root);
|
|
556
|
+
const cur = fold(readLines(file)).setups.get(id);
|
|
557
|
+
if (!cur) return refuse(404, `no recurring report ${String(id).slice(0, 40)}`);
|
|
558
|
+
if (cur.source === 'work') unregisterSchedule(opts.scheduleStore, cur, opts.wsId);
|
|
559
|
+
append(file, { ev: 'remove', id }, { now: opts.now, by: opts.by });
|
|
560
|
+
return { ok: true };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ── the Ask box ──────────────────────────────────────────────────────────────────────────────────────
|
|
564
|
+
function askStart(file, { text, ws, by, now }) {
|
|
565
|
+
if (!oneLine(String(text || '').replace(/\s+/g, ' '), LIMITS.text)) return refuse(400, `say what report you want, in up to ${LIMITS.text} characters`);
|
|
566
|
+
const st = fold(readLines(file));
|
|
567
|
+
if (st.asks.size >= LIMITS.asks) return refuse(409, 'there are too many open requests. Cancel one first');
|
|
568
|
+
const ask = newId('ask');
|
|
569
|
+
append(file, { ev: 'ask-start', ask, text: String(text).replace(/\s+/g, ' ').trim(), ws: ws || null }, { now, by });
|
|
570
|
+
return { ok: true, id: ask };
|
|
571
|
+
}
|
|
572
|
+
function askChange(file, { id, text, by, now }) {
|
|
573
|
+
if (!oneLine(String(text || '').replace(/\s+/g, ' '), LIMITS.text)) return refuse(400, `say what to change, in up to ${LIMITS.text} characters`);
|
|
574
|
+
const a = fold(readLines(file)).asks.get(id);
|
|
575
|
+
if (!a) return refuse(404, 'no such request');
|
|
576
|
+
if (a.state === 'running') return refuse(409, 'PMOS is still working on this request');
|
|
577
|
+
append(file, { ev: 'proposal-change', ask: id, text: String(text).replace(/\s+/g, ' ').trim() }, { now, by });
|
|
578
|
+
return { ok: true, ask: a };
|
|
579
|
+
}
|
|
580
|
+
function askCancel(file, { id, by, now }) {
|
|
581
|
+
if (!fold(readLines(file)).asks.has(id)) return refuse(404, 'no such request');
|
|
582
|
+
append(file, { ev: 'proposal-cancel', ask: id }, { now, by });
|
|
583
|
+
return { ok: true };
|
|
584
|
+
}
|
|
585
|
+
function askEnd(file, { id, ok, error, now }) {
|
|
586
|
+
const a = fold(readLines(file)).asks.get(id);
|
|
587
|
+
if (!a) return { ok: true, gone: true };
|
|
588
|
+
append(file, { ev: 'ask-end', ask: id, ok: !!ok, ...(error ? { error: String(error).slice(0, 300) } : {}) }, { now });
|
|
589
|
+
return { ok: true };
|
|
590
|
+
}
|
|
591
|
+
/** The report-setup run files ONE proposal for its ask. A second one revises it. */
|
|
592
|
+
function fileProposal(file, { ask, proposal, by, now }) {
|
|
593
|
+
const st = fold(readLines(file));
|
|
594
|
+
const a = st.asks.get(ask);
|
|
595
|
+
if (!a) return refuse(404, 'that request is not open');
|
|
596
|
+
if (a.state !== 'running') return refuse(409, 'that request has its proposal already');
|
|
597
|
+
const c = checkProposal(proposal);
|
|
598
|
+
if (!c.ok) return refuse(400, c.error);
|
|
599
|
+
append(file, { ev: 'proposal', ask, proposal: c.proposal }, { now, by });
|
|
600
|
+
return { ok: true, result: 'filed' };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ── the finder ───────────────────────────────────────────────────────────────────────────────────────
|
|
604
|
+
function findStart(file, { now } = {}) { const run = newId('frun'); append(file, { ev: 'find-start', run }, { now }); return run; }
|
|
605
|
+
function findEnd(file, { run, ok, error, now }) {
|
|
606
|
+
const st = fold(readLines(file));
|
|
607
|
+
const found = openFindings(st).filter((f) => f.run === run).length;
|
|
608
|
+
append(file, { ev: 'find-end', run, ok: !!ok, found, ...(error ? { error: String(error).slice(0, 300) } : {}) }, { now });
|
|
609
|
+
return found;
|
|
610
|
+
}
|
|
611
|
+
/** A found report. Refuses a dismissed pattern (`dismissed`), a repeat (`duplicate`) and a report already set up (`duplicate`). */
|
|
612
|
+
function fileFinding(file, input, { run, by, now } = {}) {
|
|
613
|
+
const x = isObj(input) ? input : {};
|
|
614
|
+
if (!oneLine(x.name, LIMITS.name)) return refuse(400, `name is one line of 1 to ${LIMITS.name} characters`);
|
|
615
|
+
if (!oneLine(x.why, 300)) return refuse(400, 'why is one line of 1 to 300 characters');
|
|
616
|
+
if (!CONFIDENCE.includes(x.confidence)) return refuse(400, `confidence is ${CONFIDENCE.join(' or ')}`);
|
|
617
|
+
const ev = checkEvidence(x.evidence); if (!ev.ok) return refuse(400, ev.error);
|
|
618
|
+
const pr = checkProposal(x.proposal); if (!pr.ok) return refuse(400, pr.error);
|
|
619
|
+
const st = fold(readLines(file));
|
|
620
|
+
const pattern = patternOf(x.name, x.pattern);
|
|
621
|
+
if (isDismissed(st, pattern, x.name)) return { ok: false, status: 409, error: 'dismissed', result: 'dismissed' };
|
|
622
|
+
const slug = slugOf(x.name);
|
|
623
|
+
const dup = [...st.findings.values()].some((f) => f.state === 'open' && (f.pattern === pattern || slugOf(f.name) === slug))
|
|
624
|
+
|| [...st.setups.values()].some((s) => slugOf(s.name) === slug || s.id === slug);
|
|
625
|
+
if (dup) return { ok: false, status: 409, error: 'duplicate', result: 'duplicate' };
|
|
626
|
+
const mine = [...st.findings.values()].filter((f) => f.run && f.run === run).length;
|
|
627
|
+
if (run && mine >= LIMITS.findings) return refuse(409, `this run filed ${LIMITS.findings} findings already; file no more`);
|
|
628
|
+
const id = newId('rf');
|
|
629
|
+
append(file, { ev: 'finding', id, run: run || null, pattern, name: x.name.trim(), why: x.why.trim(), evidence: ev.evidence, confidence: x.confidence, proposal: pr.proposal }, { now, by });
|
|
630
|
+
return { ok: true, result: 'filed', id };
|
|
631
|
+
}
|
|
632
|
+
function dismissFinding(file, { id, by, now }) {
|
|
633
|
+
const f = fold(readLines(file)).findings.get(id);
|
|
634
|
+
if (!f) return refuse(404, 'no such finding');
|
|
635
|
+
append(file, { ev: 'finding-dismiss', id, pattern: f.pattern }, { now, by });
|
|
636
|
+
return { ok: true };
|
|
637
|
+
}
|
|
638
|
+
function setFinder(file, { cadence, sources, by, now }) {
|
|
639
|
+
const line = {};
|
|
640
|
+
if (cadence !== undefined) { if (!FINDER_CADENCES.includes(cadence)) return refuse(400, `cadence is ${FINDER_CADENCES.join(' or ')}`); line.cadence = cadence; }
|
|
641
|
+
if (sources !== undefined) {
|
|
642
|
+
if (!isObj(sources)) return refuse(400, 'sources is { calendar, slack, mail, drive }: true or false');
|
|
643
|
+
line.sources = {};
|
|
644
|
+
for (const [k, v] of Object.entries(sources)) {
|
|
645
|
+
if (!FINDER_SOURCES.includes(k) || typeof v !== 'boolean') return refuse(400, `sources hold ${FINDER_SOURCES.join(', ')}, each true or false`);
|
|
646
|
+
line.sources[k] = v;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (!Object.keys(line).length) return refuse(400, 'say a cadence or sources');
|
|
650
|
+
append(file, { ev: 'finder', ...line }, { now, by });
|
|
651
|
+
return { ok: true, finder: fold(readLines(file)).finder };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ── the documents ────────────────────────────────────────────────────────────────────────────────────
|
|
655
|
+
/** `<state>/reports/<id>/<slot>.md` for a dashboard-sourced setup: the dashboard's report frame as a document. Refuses an existing or issued slot. */
|
|
656
|
+
function writeDocument(root, setup, slot, markdown, { now, record = true } = {}) {
|
|
657
|
+
if (!DATE_RE.test(String(slot))) return refuse(400, 'the slot is a date');
|
|
658
|
+
const abs = path.resolve(String(root));
|
|
659
|
+
const rel = reportDraft.draftPath(abs, setup.id, slot);
|
|
660
|
+
const file = path.join(abs, rel);
|
|
661
|
+
const issues = require('./issues.js');
|
|
662
|
+
if (issues.isIssued(issues.readIssues(abs), setup.id, slot)) return refuse(409, `${setup.id} ${slot} is issued and frozen. Nothing was written`);
|
|
663
|
+
if (fs.existsSync(file)) return refuse(409, `${rel} exists already. Nothing was written`);
|
|
664
|
+
const tmp = `${file}.tmp-${crypto.randomBytes(6).toString('hex')}`;
|
|
665
|
+
try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(tmp, markdown, { flag: 'wx' }); fs.renameSync(tmp, file); }
|
|
666
|
+
catch (e) { try { fs.unlinkSync(tmp); } catch { /* not made */ } return refuse(500, 'could not write the document: ' + String((e && e.message) || e).slice(0, 160)); }
|
|
667
|
+
if (record) append(ledgerFile(abs), { ev: 'drafted', id: setup.id, slot, path: rel }, { now });
|
|
668
|
+
return { ok: true, path: rel };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** The markdown of a dashboard-sourced document: a frame (lib/report-frame.js frameOf) as headings and bullets. */
|
|
672
|
+
function frameMarkdown(setup, F, slot, now) {
|
|
673
|
+
const q = (v) => `"${String(v == null ? '' : v).replace(/["\\]/g, "'")}"`;
|
|
674
|
+
const out = ['---', 'type: Reference', `title: ${q(`${setup.name} ${slot}`)}`, `description: ${q(`The ${setup.name} report for ${slot}, drafted from a dashboard. Nothing was sent.`)}`,
|
|
675
|
+
`timestamp: ${localDay(new Date(now))}`, `cadence: ${setup.id}`, `slot: ${slot}`, 'status: draft', `output: ${setup.output}`, `source: ${setup.source}`, 'made_by: schedule', '---', '',
|
|
676
|
+
`# ${F.title} for ${F.audience}, ${F.today}`, '', '## Headline', '', F.headline, '', '## Sections', ''];
|
|
677
|
+
for (const s of F.sections) { out.push(`### ${s.title}`, '', `- ${s.line} [reported]`); for (const n of s.nums) out.push(`- ${n} [reported]`); out.push(''); }
|
|
678
|
+
out.push('## Needs your call', '');
|
|
679
|
+
if (F.needs.length) for (const n of F.needs) out.push(`- ${n} [reported]`); else out.push('Nothing needs your call in this scope.');
|
|
680
|
+
out.push('', '## Sources', '', `${F.sources.length ? F.sources.join('; ') : 'none'}. Read on ${F.today}.`, '');
|
|
681
|
+
return out.join('\n');
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ── the migration ────────────────────────────────────────────────────────────────────────────────────
|
|
685
|
+
const DASH_PLAN = Object.freeze({ weekly: { cadence: 'weekly', day: 'Mon', time: '08:00' }, monthly: { cadence: 'monthly', dayOfMonth: 1, time: '08:00' } });
|
|
686
|
+
const dashTitle = (key) => (key === 'portfolio' ? 'Portfolio' : `${key.startsWith('board:') ? 'Board' : 'Initiative'}: ${key.slice(key.indexOf(':') + 1)}`);
|
|
687
|
+
const keyOfSource = (source) => String(source || '').replace(/^dashboard:/, '');
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Move the dashboard schedules (<runtimeDir>/report-dashboards/schedules.json) into recurring reports, once. The old file is read and never
|
|
691
|
+
* written again: it stays as the migration record. A `migrated` line is the marker; a second call changes nothing.
|
|
692
|
+
* opts: { runtimeDir, wsId, isHome, titleOf(key), outbox (the folded outbox entries), now, by }
|
|
693
|
+
*/
|
|
694
|
+
function migrate(root, opts = {}) {
|
|
695
|
+
const file = ledgerFile(root);
|
|
696
|
+
const st = fold(readLines(file));
|
|
697
|
+
if (st.migrated) return { ok: true, already: true, count: 0, keys: st.migrated.keys };
|
|
698
|
+
const rs = require('./report-schedules.js');
|
|
699
|
+
const mine = Object.values(rs.readSchedules(opts.runtimeDir || '')).filter((s) => (s.ws ? s.ws === opts.wsId : !!opts.isHome));
|
|
700
|
+
const keys = [];
|
|
701
|
+
const now = opts.now == null ? new Date() : new Date(opts.now);
|
|
702
|
+
for (const s of mine) {
|
|
703
|
+
const plan = DASH_PLAN[s.plan];
|
|
704
|
+
if (!plan) continue;
|
|
705
|
+
const key = s.key;
|
|
706
|
+
const source = `dashboard:${key}`;
|
|
707
|
+
if ([...st.setups.values()].some((x) => x.source === source)) { keys.push(key); continue; }
|
|
708
|
+
const title = (typeof opts.titleOf === 'function' && opts.titleOf(key)) || dashTitle(key);
|
|
709
|
+
const r = setUp(root, { name: title, purpose: `The ${title} dashboard as a report for ${s.audience}.`, audience: s.audience, source, output: 'pdf', schedule: plan,
|
|
710
|
+
sources: [`The ${title} dashboard`], sections: FRAME.slice(), learned_from: 'Moved from the dashboard schedule.', fixes: [] },
|
|
711
|
+
{ now, by: opts.by, wsId: opts.wsId, lastRun: s.lastRun || now.toISOString(), id: freeId(root, fold(readLines(file)), 'dashboard-' + key.replace(/[:]/g, '-')), migrated: true });
|
|
712
|
+
if (r.ok) keys.push(key);
|
|
713
|
+
}
|
|
714
|
+
// Its drafts in the outbox become draft documents, so none is lost when the Drafts tab goes.
|
|
715
|
+
for (const e of opts.outbox || []) {
|
|
716
|
+
if (!e || e.made_by !== 'schedule' || !e.schedule_key || e.state === 'sent' || !keys.includes(e.schedule_key)) continue;
|
|
717
|
+
const setup = [...fold(readLines(file)).setups.values()].find((x) => x.source === `dashboard:${e.schedule_key}`);
|
|
718
|
+
if (!setup) continue;
|
|
719
|
+
const slot = localDay(new Date(Date.parse(e.created_at || e.at || now)));
|
|
720
|
+
const md = ['---', 'type: Reference', `title: "${String(e.subject || setup.name).replace(/["\\]/g, "'")}"`, `timestamp: ${localDay(now)}`, `cadence: ${setup.id}`, `slot: ${slot}`, 'status: draft',
|
|
721
|
+
`output: ${setup.output}`, `source: ${setup.source}`, 'made_by: schedule', '---', '', `# ${e.subject || setup.name}`, '', String(e.body || ''), ''].join('\n');
|
|
722
|
+
writeDocument(root, setup, slot, md, { now, record: false });
|
|
723
|
+
}
|
|
724
|
+
append(file, { ev: 'migrated', keys, count: keys.length }, { now, by: opts.by });
|
|
725
|
+
return { ok: true, already: false, count: keys.length, keys };
|
|
726
|
+
}
|
|
727
|
+
const migratedKeys = (root) => { const m = stateOf(root).migrated; return new Set(m ? m.keys : []); };
|
|
728
|
+
/** The dashboard keys the old schedule tick must leave alone: the migrated ones, and every key a dashboard-sourced setup holds now. */
|
|
729
|
+
function dashboardKeys(root) {
|
|
730
|
+
const st = stateOf(root);
|
|
731
|
+
const keys = new Set(st.migrated ? st.migrated.keys : []);
|
|
732
|
+
for (const s of st.setups.values()) if (String(s.source).startsWith('dashboard:')) keys.add(keyOfSource(s.source));
|
|
733
|
+
return keys;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
module.exports = {
|
|
737
|
+
LEDGER, EVENTS, OUTPUTS, CADENCES, DAYS, FINDER_CADENCES, FINDER_SOURCES, FINDER_DEFAULTS, EVIDENCE_KINDS, CONFIDENCE, LIMITS, ID_RE, ASK_RE, FINDING_RE, RUN_RE, FRAME,
|
|
738
|
+
ledgerFile, stateDir, append, readLines, fold, stateOf, openFindings, isDismissed, slugOf, patternOf, checkProposal, checkEvidence, checkSchedule,
|
|
739
|
+
scheduleLabel, scheduleOf, firstSlot, initialLastRun, nextDraftAt, definitionText, writeDefinition, definitionsOf, definitionFile, shaOf,
|
|
740
|
+
fileScheduleStore, scheduleFor, registerSchedule, updateSchedule, unregisterSchedule,
|
|
741
|
+
setUp, change, pause, resume, remove, askStart, askChange, askCancel, askEnd, fileProposal, findStart, findEnd, fileFinding, dismissFinding, setFinder,
|
|
742
|
+
writeDocument, frameMarkdown, migrate, migratedKeys, dashboardKeys, dashTitle, keyOfSource, longDate, localDay,
|
|
743
|
+
};
|