create-pmos 0.123.0 → 0.125.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.
Files changed (127) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/scripts/nine-stages.js +81 -0
  6. package/payload/scripts/workflow-state.js +2 -0
  7. package/payload/state/discoveries.md +1 -1
  8. package/payload/state/log.md +1 -1
  9. package/workspace/c3-one-home-test.js +131 -0
  10. package/workspace/fixtures/initiatives-api/list.json +171 -3
  11. package/workspace/fixtures/initiatives-api/roadmap.json +228 -4
  12. package/workspace/fixtures/initiatives-api/sheet.json +57 -1
  13. package/workspace/fixtures/one-home-baseline.json +83 -0
  14. package/workspace/guide/layout.md +2 -0
  15. package/workspace/guide/notes.md +1 -0
  16. package/workspace/lib/engine/nine-stages.js +81 -0
  17. package/workspace/lib/engine/workflow-state.js +2 -0
  18. package/workspace/lib/facts-async.js +51 -0
  19. package/workspace/lib/item-steps.js +52 -0
  20. package/workspace/lib/jira-status.js +8 -3
  21. package/workspace/lib/jobs-to-be-done.js +52 -0
  22. package/workspace/lib/outbox-store.js +87 -0
  23. package/workspace/lib/pm-items.js +115 -0
  24. package/workspace/lib/route-modules.js +21 -0
  25. package/workspace/lib/routes-briefing.js +100 -0
  26. package/workspace/lib/routes-c3-init.js +53 -0
  27. package/workspace/lib/routes-c3-life.js +119 -0
  28. package/workspace/lib/routes-c3-strategy.js +169 -0
  29. package/workspace/lib/routes-c3-timeline.js +191 -0
  30. package/workspace/lib/routes-calendar-import.js +60 -0
  31. package/workspace/lib/routes-calendar-views.js +61 -0
  32. package/workspace/lib/routes-doc-health.js +79 -0
  33. package/workspace/lib/routes-doc-sheet.js +169 -0
  34. package/workspace/lib/routes-forecast.js +85 -0
  35. package/workspace/lib/routes-hooks.js +121 -0
  36. package/workspace/lib/routes-initiatives.js +3 -0
  37. package/workspace/lib/routes-intake.js +24 -0
  38. package/workspace/lib/routes-outbox.js +97 -0
  39. package/workspace/lib/routes-pm-items.js +216 -0
  40. package/workspace/lib/routes-records.js +61 -0
  41. package/workspace/lib/routes-releases.js +129 -0
  42. package/workspace/lib/routes-report-facts.js +84 -0
  43. package/workspace/lib/routes-report-families.js +32 -0
  44. package/workspace/lib/routes-roadmap-ops.js +141 -0
  45. package/workspace/lib/routes-runs.js +119 -0
  46. package/workspace/lib/routes-signals.js +50 -0
  47. package/workspace/lib/run-answers.js +69 -0
  48. package/workspace/package-lock.json +2 -2
  49. package/workspace/package.json +1 -1
  50. package/workspace/public/c3.html +26 -0
  51. package/workspace/public/pm/briefing-deck.js +415 -0
  52. package/workspace/public/pm/briefing-model.js +176 -0
  53. package/workspace/public/pm/c3-breakdown.js +240 -0
  54. package/workspace/public/pm/c3-brief.js +278 -0
  55. package/workspace/public/pm/c3-cal-strategy.js +528 -0
  56. package/workspace/public/pm/c3-case.js +173 -0
  57. package/workspace/public/pm/c3-demo-brief-ix.js +350 -0
  58. package/workspace/public/pm/c3-demo-cal-ix.js +74 -0
  59. package/workspace/public/pm/c3-demo-cal2.js +66 -0
  60. package/workspace/public/pm/c3-demo-cal3.js +173 -0
  61. package/workspace/public/pm/c3-demo-data.js +560 -0
  62. package/workspace/public/pm/c3-demo-init.js +373 -0
  63. package/workspace/public/pm/c3-demo-life.js +77 -0
  64. package/workspace/public/pm/c3-demo-misc2.js +46 -0
  65. package/workspace/public/pm/c3-demo-plan2.js +30 -0
  66. package/workspace/public/pm/c3-demo-rep-ix.js +126 -0
  67. package/workspace/public/pm/c3-demo-rep2.js +92 -0
  68. package/workspace/public/pm/c3-demo-term.js +153 -0
  69. package/workspace/public/pm/c3-demo-time.js +49 -0
  70. package/workspace/public/pm/c3-demo-work-ix.js +80 -0
  71. package/workspace/public/pm/c3-demo-work2.js +13 -0
  72. package/workspace/public/pm/c3-demo.js +113 -0
  73. package/workspace/public/pm/c3-doc-sheet.js +314 -0
  74. package/workspace/public/pm/c3-dock.js +180 -0
  75. package/workspace/public/pm/c3-docs.js +290 -0
  76. package/workspace/public/pm/c3-expand.js +96 -0
  77. package/workspace/public/pm/c3-init-extras.js +170 -0
  78. package/workspace/public/pm/c3-lifecycle-panels.js +476 -0
  79. package/workspace/public/pm/c3-misc-panels.js +89 -0
  80. package/workspace/public/pm/c3-plan-panels.js +163 -0
  81. package/workspace/public/pm/c3-plan.js +192 -0
  82. package/workspace/public/pm/c3-roadmap.js +249 -0
  83. package/workspace/public/pm/c3-sheet.js +107 -0
  84. package/workspace/public/pm/c3-shell.js +637 -0
  85. package/workspace/public/pm/c3-signals.js +196 -0
  86. package/workspace/public/pm/c3-strategy-panels.js +45 -0
  87. package/workspace/public/pm/c3-swipe.js +138 -0
  88. package/workspace/public/pm/c3-terminal-data.js +26 -0
  89. package/workspace/public/pm/c3-terminal-edge.js +91 -0
  90. package/workspace/public/pm/c3-terminal-icons.js +41 -0
  91. package/workspace/public/pm/c3-terminal-results.js +141 -0
  92. package/workspace/public/pm/c3-terminal.js +52 -0
  93. package/workspace/public/pm/c3-tickets.js +163 -0
  94. package/workspace/public/pm/c3-timeline.js +201 -0
  95. package/workspace/public/pm/c3-toast.js +16 -0
  96. package/workspace/public/pm/calendar-views.js +1015 -0
  97. package/workspace/public/pm/console3-lift.md +220 -0
  98. package/workspace/public/pm/console3.css +1480 -0
  99. package/workspace/public/pm/dest-initiatives.js +16 -3
  100. package/workspace/public/pm/dest-mywork.js +8 -1
  101. package/workspace/public/pm/dest-notes.js +25 -3
  102. package/workspace/public/pm/dest-reports.js +47 -1
  103. package/workspace/public/pm/dest-today.js +55 -1
  104. package/workspace/public/pm/doc-health-panel.js +110 -0
  105. package/workspace/public/pm/doc-rail.js +9 -3
  106. package/workspace/public/pm/lifecycle-lens-model.js +228 -0
  107. package/workspace/public/pm/lifecycle-lens.js +261 -0
  108. package/workspace/public/pm/new-initiative-sheet.js +116 -21
  109. package/workspace/public/pm/outbox-panel.js +125 -0
  110. package/workspace/public/pm/pm-work-view.js +919 -0
  111. package/workspace/public/pm/releases-view.js +248 -0
  112. package/workspace/public/pm/report-families-view.js +503 -0
  113. package/workspace/public/pm/run-card.js +244 -0
  114. package/workspace/public/pm/sessions-panel.js +149 -0
  115. package/workspace/public/pm/shell-bell.js +162 -0
  116. package/workspace/public/pm/shell-model.js +83 -0
  117. package/workspace/public/pm/shell-phone.js +124 -0
  118. package/workspace/public/pm/shell-readaloud.js +53 -0
  119. package/workspace/public/pm/shell-recording.js +110 -0
  120. package/workspace/public/pm/shell-ticker.js +90 -0
  121. package/workspace/public/pm/slide-confirm.js +163 -0
  122. package/workspace/public/pm/timeline-forecast-view.js +164 -0
  123. package/workspace/public/pm/today-day.js +8 -2
  124. package/workspace/public/pm/tokens.css +1 -0
  125. package/workspace/server.js +5 -2
  126. package/workspace/test-files.json +8 -0
  127. package/workspace/test-lists/browser-suites-green.json +5 -0
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+ /* routes-c3-strategy.js — read-only data for the Quarter and Year panes of the Calendar on /c3.html. Nothing is written here.
3
+ *
4
+ * GET /api/c3-strategy → { ok, today, north, outcomes, signals, synergy, timeline, maturity, sources }
5
+ *
6
+ * north the KR metric record that carries `north_star: true`, as monthly points { at:'YYYY-MM', actual, plan, proj }, or null
7
+ * outcomes one row per KR metric record: now, where the straight line from baseline to target says it should be today, and the
8
+ * forecast landing from the readings' own slope (null with fewer than two readings)
9
+ * signals open trips of the signal rules (scripts/signal-rules.js), plus a KR that is behind (or ahead of) its line by 10 percent
10
+ * of the baseline-to-target span, plus a KR that is behind at three readings in a row and not closing (the pivot rule)
11
+ * synergy bets by key result: a dot where a bet's parent_kr names the key result; a key result with no active bet is a gap
12
+ * timeline bets by quarter from their horizon (now = this quarter, next = the one after, later = the third), and the hypotheses'
13
+ * review dates inside the four quarters as checkpoints
14
+ * maturity null: this repository holds no pillar-maturity record, so the page says so
15
+ *
16
+ * Sources: planning/records/{kr-metrics,bets,hypotheses}.jsonl through scripts/records, and <runtime>/signals/signals.jsonl. No facts
17
+ * reader is used, so the route never starts a cold repository read. A number that no record holds is null, never invented. */
18
+ const path = require('path');
19
+ const fs = require('fs');
20
+ const records = require(path.join(__dirname, '..', '..', 'scripts', 'records', 'index.js'));
21
+ const sigRules = require(path.join(__dirname, '..', '..', 'scripts', 'signal-rules.js'));
22
+ const ledger = require(path.join(__dirname, '..', '..', 'scripts', 'trust', 'ledger.js'));
23
+
24
+ const EARLY = 0.10; // the repository's early-warning share (scripts/calendar-model.js EARLY_WARNING)
25
+ const OFF = 0.50; // behind the line by half the span or more reads as off course
26
+ const DAY = 864e5;
27
+ const pad = (n) => String(n).padStart(2, '0');
28
+ const isDate = (s) => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(Date.parse(s.slice(0, 10) + 'T00:00:00Z'));
29
+ const ms = (s) => Date.parse(String(s).slice(0, 10) + 'T00:00:00Z');
30
+ const fin = (v) => typeof v === 'number' && Number.isFinite(v);
31
+ const r2 = (v) => (fin(v) ? Math.round(v * 100) / 100 : null);
32
+ const rootOf = (ctx) => (ctx && ctx.recordsDir) || path.join((ctx && ctx.repoRoot) || path.join(__dirname, '..', '..'), 'planning', 'records');
33
+ const monthKey = (t) => { const d = new Date(t); return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1); };
34
+ const monthIdx = (k) => Number(k.slice(0, 4)) * 12 + Number(k.slice(5, 7)) - 1;
35
+ const keyOfIdx = (i) => Math.floor(i / 12) + '-' + pad((i % 12) + 1);
36
+
37
+ /** One KR metric record -> an outcome row. today is 'YYYY-MM-DD'. */
38
+ function outcomeOf(k, today) {
39
+ const name = String(k.metric || k.rid);
40
+ const base = Number(k.baseline); const tgt = Number(k.target);
41
+ const rs = (k.readings || []).filter((x) => fin(Number(x.value)) && isDate(x.at)).map((x) => ({ at: String(x.at).slice(0, 10), v: Number(x.value) })).sort((a, b) => a.at.localeCompare(b.at));
42
+ const o = { id: k.rid, name, pillar: k.pillar ? String(k.pillar) : '', unit: k.unit ? String(k.unit) : '', start: fin(base) ? base : null, target: fin(tgt) ? tgt : null, by: k.by || null,
43
+ now: null, expected: null, forecast: null, status: 'unscored', lead: k.note ? String(k.note) : '', reason: '', readings: rs };
44
+ if (!fin(base) || !fin(tgt) || base === tgt || !isDate(k.by)) { o.reason = 'The record needs a baseline, a different target and a by date.'; return o; }
45
+ if (!rs.length) { o.reason = 'No reading is recorded yet.'; return o; }
46
+ const t0 = isDate(k.start) ? ms(k.start) : ms(rs[0].at);
47
+ const t1 = ms(k.by);
48
+ const frac = t1 > t0 ? Math.max(0, Math.min(1, (ms(today) - t0) / (t1 - t0))) : 1;
49
+ const span = tgt - base;
50
+ const now = rs[rs.length - 1].v;
51
+ const expected = base + span * frac;
52
+ const shortfall = (expected - now) / Math.abs(span) * Math.sign(span);
53
+ o.now = r2(now); o.expected = r2(expected); o.shortfall = r2(shortfall);
54
+ if (rs.length >= 2) {
55
+ const a = rs[0]; const b = rs[rs.length - 1];
56
+ const dt = (ms(b.at) - ms(a.at)) / DAY;
57
+ if (dt > 0) o.forecast = r2(b.v + (b.v - a.v) / dt * ((t1 - ms(b.at)) / DAY));
58
+ }
59
+ o.status = shortfall >= OFF ? 'off' : shortfall >= EARLY ? 'drift' : 'ok';
60
+ // the pivot rule of scripts/calendar-model.js: at least 3 readings, each behind the line by the early-warning share, and the gap is not closing
61
+ if (rs.length >= 3) {
62
+ const sf = rs.map((x) => { const f = t1 > t0 ? Math.max(0, Math.min(1, (ms(x.at) - t0) / (t1 - t0))) : 1; return (base + span * f - x.v) / Math.abs(span) * Math.sign(span); });
63
+ o.pivot = sf.every((v) => v >= EARLY) && sf[sf.length - 1] >= sf[0];
64
+ }
65
+ return o;
66
+ }
67
+
68
+ /** The north star: monthly points from the first reading to the by date (plan), the readings (actual) and the slope (proj). */
69
+ function northOf(k, o, today) {
70
+ if (!k || !o || !o.readings.length || o.start === null || o.target === null) return null;
71
+ const rs = o.readings;
72
+ const t0 = isDate(k.start) ? ms(k.start) : ms(rs[0].at); const t1 = ms(k.by);
73
+ const i0 = monthIdx(monthKey(t0)); const i1 = Math.max(monthIdx(monthKey(t1)), monthIdx(today.slice(0, 7)));
74
+ const byMonth = new Map(); rs.forEach((x) => byMonth.set(x.at.slice(0, 7), x.v));
75
+ const nowI = monthIdx(rs[rs.length - 1].at.slice(0, 7));
76
+ const last = rs[rs.length - 1].v;
77
+ const back = [...byMonth.entries()].filter(([m]) => monthIdx(m) >= nowI - 3 && monthIdx(m) < nowI).sort();
78
+ const slope = back.length ? (last - back[0][1]) / (nowI - monthIdx(back[0][0])) : null;
79
+ const pts = [];
80
+ for (let i = i0; i <= i1; i++) {
81
+ const key = keyOfIdx(i); const tm = Date.UTC(Math.floor(i / 12), i % 12, 1);
82
+ const f = t1 > t0 ? Math.max(0, Math.min(1, (tm - t0) / (t1 - t0))) : 1;
83
+ pts.push({ at: key, actual: byMonth.has(key) ? r2(byMonth.get(key)) : null, plan: r2(o.start + (o.target - o.start) * f), proj: slope !== null && i >= nowI ? r2(last + slope * (i - nowI)) : null });
84
+ }
85
+ return { label: o.name, unit: o.unit, now: keyOfIdx(nowI), points: pts, by: k.by };
86
+ }
87
+
88
+ /** Signals: open rule trips, then the KR-derived ones. */
89
+ function signalsOf(outcomes, sig, readingsByMetric) {
90
+ const out = [];
91
+ const SEV = { watch: 'watch', act: 'drift', critical: 'pivot' };
92
+ for (const t of (sig && sig.open) || []) {
93
+ const rule = ((sig.rules) || []).find((r) => r.id === t.rule_id) || {};
94
+ const series = (readingsByMetric[t.reading.metric] || []).slice(-8);
95
+ out.push({ id: 'rule:' + t.rule_id, sev: SEV[t.severity] || 'watch', t: t.reading.metric + ' ' + t.op + ' ' + t.threshold, ev: 'The latest reading is ' + t.reading.value + (t.reading.at ? ' on ' + String(t.reading.at).slice(0, 10) : '') + '.',
96
+ thr: 'Rule ' + (rule.id || t.rule_id) + ': severity ' + t.severity, ttt: 'tripped', sp: series, source: 'signal rule' });
97
+ }
98
+ for (const o of outcomes) {
99
+ if (o.status === 'unscored') continue;
100
+ const sp = o.readings.map((x) => x.v).slice(-8);
101
+ const u = o.unit.trim();
102
+ if (o.pivot) out.push({ id: 'kr:' + o.id, sev: 'pivot', t: o.name + ' has been behind the line three readings in a row', ev: 'Now ' + o.now + u + ' against ' + o.expected + u + ' expected today, and the gap is not closing.', thr: 'Pivot rule: 3 readings behind the line and not closing', ttt: 'tripped', sp, source: 'KR metric' });
103
+ else if (o.status === 'drift' || o.status === 'off') out.push({ id: 'kr:' + o.id, sev: 'drift', t: o.name + ' is behind its line', ev: 'Now ' + o.now + u + ' against ' + o.expected + u + ' expected today' + (o.forecast !== null ? '. At this slope it lands at ' + o.forecast + u + '.' : '.'), thr: 'Early warning: behind the line by 10 percent of the span', ttt: '', sp, source: 'KR metric' });
104
+ else if (o.shortfall <= -EARLY) out.push({ id: 'kr:' + o.id, sev: 'up', t: o.name + ' is ahead of its line', ev: 'Now ' + o.now + u + ' against ' + o.expected + u + ' expected today.', thr: 'Ahead of the line by 10 percent of the span', ttt: '', sp, source: 'KR metric' });
105
+ }
106
+ return out;
107
+ }
108
+
109
+ function synergyOf(krs, bets) {
110
+ if (!bets.length) return null;
111
+ const cols = krs.map((k) => ({ id: k.rid, n: String(k.metric || k.rid), icon: 'target' }));
112
+ const hit = (b, c) => !!b.parent_kr && (String(b.parent_kr) === c.id || String(b.parent_kr) === c.n);
113
+ const rows = bets.slice().sort((a, b) => a.rank - b.rank).map((b) => ({ id: b.rid, title: String(b.title), parked: b.horizon === 'later', weights: cols.map((c) => (hit(b, c) ? 1 : 0)), initiative: b.initiative || null }));
114
+ const notes = [];
115
+ cols.forEach((c, j) => { if (!rows.some((r) => !r.parked && r.weights[j] > 0)) notes.push({ icon: 'alert', tone: 'bad', text: c.n + ' has no active bet.' }); });
116
+ rows.forEach((r) => { if (!r.weights.some((w) => w > 0)) notes.push({ icon: 'info', tone: 'warn', text: r.title + ' is not anchored to a KR metric, so it adds to no outcome.' }); });
117
+ return { columns: cols, rows, notes, unit: 'key results' };
118
+ }
119
+
120
+ function timelineOf(bets, hyps, today) {
121
+ if (!bets.length && !hyps.length) return null;
122
+ const y = Number(today.slice(0, 4)); const q0 = Math.floor((Number(today.slice(5, 7)) - 1) / 3);
123
+ const qs = [0, 1, 2, 3].map((i) => { const qi = q0 + i; return { y: y + Math.floor(qi / 4), q: qi % 4 }; });
124
+ const labels = qs.map((x) => 'Q' + (x.q + 1) + " '" + String(x.y).slice(2));
125
+ const rows = bets.slice().sort((a, b) => a.rank - b.rank).map((b) => ({ id: b.rid, title: String(b.title), sub: b.parent_kr ? String(b.parent_kr) : '', initiative: b.initiative || null, st: 'none',
126
+ cells: ['', '', '', ''].map((_, i) => (b.horizon === 'now' && i === 0 ? 'c' : b.horizon === 'next' && i === 1 ? 'c' : b.horizon === 'later' && i === 2 ? 'l' : '')) }));
127
+ const cps = [];
128
+ hyps.filter((h) => isDate(h.review_by)).forEach((h) => {
129
+ const d = h.review_by.slice(0, 10); const dy = Number(d.slice(0, 4)); const dq = Math.floor((Number(d.slice(5, 7)) - 1) / 3);
130
+ const idx = qs.findIndex((x) => x.y === dy && x.q === dq);
131
+ if (idx < 0) return;
132
+ const qs0 = Date.UTC(dy, dq * 3, 1); const qe = Date.UTC(dy, dq * 3 + 3, 1);
133
+ cps.push({ q: idx, f: Math.round((ms(d) - qs0) / (qe - qs0) * 100) / 100, at: d, label: String(h.title) + ', ' + d });
134
+ });
135
+ return { quarters: labels, rows, checkpoints: cps.sort((a, b) => a.at.localeCompare(b.at)) };
136
+ }
137
+
138
+ /** The pure builder: (recordsDir, signalsFile, today) -> body. */
139
+ function build(root, signalsFile, today) {
140
+ const krs = records.list(root, 'kr-metrics');
141
+ const bets = records.list(root, 'bets');
142
+ const hyps = records.list(root, 'hypotheses');
143
+ const outcomes = krs.map((k) => outcomeOf(k, today));
144
+ const nk = krs.find((k) => k.north_star === true || k.north_star === 'true');
145
+ const north = nk ? northOf(nk, outcomes.find((o) => o.id === nk.rid), today) : null;
146
+ let lines = []; try { lines = fs.existsSync(signalsFile) ? ledger.read(signalsFile) : []; } catch { lines = []; }
147
+ const sig = sigRules.foldSignals(lines);
148
+ const byMetric = {};
149
+ lines.filter((l) => l && l.kind === 'signal_reading').forEach((l) => { (byMetric[l.metric] = byMetric[l.metric] || []).push(Number(l.value)); });
150
+ return { ok: true, today, north, outcomes: outcomes.map((o) => { const { readings, pivot, shortfall, ...rest } = o; return { ...rest, pivot: !!pivot, shortfall, n_readings: readings.length }; }),
151
+ signals: signalsOf(outcomes, sig, byMetric), synergy: synergyOf(krs, bets), timeline: timelineOf(bets, hyps, today), maturity: null,
152
+ sources: { kr_metrics: krs.length, bets: bets.length, hypotheses: hyps.length, signal_rules: sig.rules.length } };
153
+ }
154
+
155
+ const todayStr = () => { const d = new Date(); return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()); };
156
+
157
+ module.exports = {
158
+ prefixes: ['/api/c3-strategy'],
159
+ build, outcomeOf, northOf, signalsOf, synergyOf, timelineOf,
160
+ handle: async (req, res, url, ctx) => {
161
+ if (url.pathname !== '/api/c3-strategy') return false;
162
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
163
+ try {
164
+ const body = build(rootOf(ctx), path.join(ctx.runtimeDir || '.', 'signals', 'signals.jsonl'), todayStr());
165
+ ctx.json(res, body, 200);
166
+ } catch (e) { ctx.json(res, { ok: false, error: String(e && e.message || e) }, 500); }
167
+ return true;
168
+ },
169
+ };
@@ -0,0 +1,191 @@
1
+ 'use strict';
2
+ /* routes-c3-timeline.js — the data of the Initiatives Timeline of /c3.html: every check of the plan as a whole frame. READ ONLY.
3
+ *
4
+ * GET /api/c3-timeline?initiative=<id>[&ws=<id>]
5
+ * → { ok, initiative, today, checks:[{check,date,label}], frames:[ { check, date, label, items, critical, forecast_end, promised,
6
+ * slip_days, points, problems, changes, events } ], sources:{plan,breakdown,jira}, notes:[...] }
7
+ * GET /api/c3-timeline?initiatives=a,b,c[&ws=<id>]
8
+ * → { ok, today, checks, rows:[{ id, cells:[{ date, forecast_end, promised, slip_days }] }] } the forecast at each check, per initiative
9
+ *
10
+ * ONE Gantt, THREE SOURCES. The frame items are the plan items of the initiative (workspace/lib/plan-items.js ledger), the Breakdown items
11
+ * (<records>/breakdown-pmos.jsonl, PMOS-only work in Discovery, Design and Launch, with the Jira tickets they wait on) and the Jira tickets of the
12
+ * initiative (the my-work read). Each frame is the plan as it stood on a check date: the events up to that day folded (scripts/plan-forecast.js
13
+ * stateAt) and planned with the sample's plan.js (checkFrames). A replay in the page steps through these frames; nothing is fetched per step.
14
+ *
15
+ * WHAT IS REAL AND WHAT IS NOT.
16
+ * - Plan items: every planned and set line of the ledger carries its dates, owner and dependencies, so a past check shows the dates the plan then
17
+ * held. An agent-proposed item enters the plan on the day it was kept. The ledger records no reason for a change; `why` is left empty.
18
+ * - Breakdown items: the store has no dates. An item starts the day it was added and lasts its points in days (1 point = 1 day); it
19
+ * waits on the Jira tickets named by its dependency lines. `why` and `by` are the ledger's own.
20
+ * - Jira tickets: the my-work read gives a due date and no history, so a ticket is drawn on its due day at EVERY check (events marked
21
+ * `synthetic`, which never make a check of their own) and no dependencies between tickets are known.
22
+ * Checks are the days the plan changed (the first is the baseline) and today; at most six. No write route exists.
23
+ */
24
+ const path = require('path');
25
+ const planItems = require('./plan-items.js');
26
+ const F = require('../../scripts/plan-forecast.js');
27
+ const roadmapOps = require('./routes-roadmap-ops.js');
28
+ const breakdown = require(path.join(__dirname, '..', '..', 'scripts', 'breakdown-ops.js'));
29
+
30
+ const localYmd = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
31
+ const DAY = 86400000;
32
+ const addDays = (s, n) => new Date(Date.parse(s + 'T00:00:00Z') + n * DAY).toISOString().slice(0, 10);
33
+ const FAR_PAST = '1970-01-01T00:00:00.000Z';
34
+ const SRC_LABEL = { local: 'Local', figma: 'Figma', calendar: 'Calendar', gmail: 'Gmail', notes: 'Notes' };
35
+
36
+ /** Plan items of one initiative → events. `live` = the fold's live items (not proposed, not withdrawn), `lines` = the raw ledger lines. */
37
+ function planEvents(lines, live, initiative) {
38
+ const out = [];
39
+ const by = new Map();
40
+ for (const l of lines || []) {
41
+ const r = l.rec;
42
+ if (!r || !r.item || !live.has(r.item)) continue;
43
+ if (!by.has(r.item)) by.set(r.item, []);
44
+ by.get(r.item).push(r);
45
+ }
46
+ for (const [id, recs] of by) {
47
+ const it = live.get(id);
48
+ if (it.initiative !== initiative) continue;
49
+ recs.sort((a, b) => (String(a.at) < String(b.at) ? -1 : String(a.at) > String(b.at) ? 1 : 0));
50
+ const planned = recs.find((r) => r.ev === 'planned');
51
+ if (!planned) continue;
52
+ const keptAt = it.kept ? ((it.history || []).filter((h) => h.ev === 'kept').map((h) => h.at).sort()[0] || planned.at) : planned.at;
53
+ const enter = String(keptAt) > String(planned.at) ? keptAt : planned.at;
54
+ const f = { title: planned.title, start: planned.start || null, due: planned.due || null, after: planned.after || [], owner: planned.owner || null,
55
+ ms: planned.milestone === true, phase: planned.stage || null, state: 'todo', origin: 'plan', src: 'Plan', key: null, points: typeof planned.points === 'number' ? planned.points : undefined };
56
+ const later = [];
57
+ for (const r of recs) {
58
+ if (r === planned || r.ev === 'kept' || r.ev === 'flagged' || r.ev === 'withdrawn') continue;
59
+ const fields = r.ev === 'set' ? mapSet(r.fields) : r.ev === 'moved' ? { state: r.to } : null;
60
+ if (!fields) continue;
61
+ if (String(r.at) <= String(enter)) Object.assign(f, fields); // before it entered the plan: part of how it entered
62
+ else later.push({ at: r.at, id, op: 'set', fields, by: r.by, why: r.note || '' });
63
+ }
64
+ out.push({ at: enter, id, op: 'add', fields: f, by: planned.by, why: planned.note || '' }, ...later);
65
+ }
66
+ return out;
67
+ }
68
+ function mapSet(fs) {
69
+ const o = {};
70
+ for (const k of Object.keys(fs || {})) {
71
+ if (k === 'milestone') o.ms = fs[k];
72
+ else if (k === 'stage') o.phase = fs[k];
73
+ else if (['title', 'owner', 'after', 'start', 'due'].includes(k)) o[k] = fs[k];
74
+ }
75
+ return o;
76
+ }
77
+
78
+ /** Breakdown lines → events (add, edit, dependency, delete, undo), for the items of one initiative. */
79
+ function breakdownEvents(lines, initiative) {
80
+ const out = [];
81
+ const deps = new Map(); const mine = new Set(); const deleted = new Map(); const keyOf = new Map();
82
+ const dateOf = (e) => String(e.at || '').slice(0, 10);
83
+ const shape = (rid, f, at) => {
84
+ const pts = typeof f.points === 'number' && f.points > 0 ? f.points : 1;
85
+ const start = f.start || dateOf({ at });
86
+ return { title: f.title, start, due: f.due || addDays(start, Math.max(1, Math.ceil(pts)) - 1), owner: f.owner || null, points: pts, after: [...(deps.get(rid) || [])],
87
+ phase: f.phase || null, state: f.done ? 'done' : 'todo', origin: 'breakdown', src: SRC_LABEL[f.source] || 'Local', key: keyOf.get(rid) };
88
+ };
89
+ for (const e of lines || []) {
90
+ if (!e || !e.rid) continue;
91
+ if (e.kind === 'add') {
92
+ if ((e.fields || {}).initiative !== initiative) continue;
93
+ mine.add(e.rid); deps.set(e.rid, []); keyOf.set(e.rid, 'PM-' + (keyOf.size + 1));
94
+ out.push({ at: e.at, id: e.rid, op: 'add', fields: shape(e.rid, e.fields, e.at), by: e.by, why: e.why || '' });
95
+ } else if (!mine.has(e.rid)) continue;
96
+ else if (e.kind === 'edit') {
97
+ const f = e.fields || {}; const set = {};
98
+ if ('title' in f) set.title = f.title;
99
+ if ('owner' in f) set.owner = f.owner || null;
100
+ if ('phase' in f) set.phase = f.phase;
101
+ if ('source' in f) set.src = SRC_LABEL[f.source] || 'Local';
102
+ if ('points' in f) { const pts = typeof f.points === 'number' && f.points > 0 ? f.points : 1; set.points = pts; }
103
+ if ('done' in f) set.state = f.done ? 'done' : 'todo';
104
+ out.push({ at: e.at, id: e.rid, op: 'set', fields: set, by: e.by, why: e.why || '' });
105
+ } else if (e.kind === 'dependency' && e.fields && e.fields.jira) {
106
+ const cur = deps.get(e.rid) || [];
107
+ deps.set(e.rid, e.fields.remove ? cur.filter((d) => d !== e.fields.jira) : [...new Set([...cur, e.fields.jira])]);
108
+ out.push({ at: e.at, id: e.rid, op: 'set', fields: { after: [...deps.get(e.rid)] }, by: e.by, why: e.why || '' });
109
+ } else if (e.kind === 'delete') {
110
+ deleted.set(e.id, e.snapshot || null);
111
+ out.push({ at: e.at, id: e.rid, op: 'remove', fields: {}, by: e.by, why: e.why || '' });
112
+ } else if (e.kind === 'undo' && deleted.has(e.corrects)) {
113
+ const snap = deleted.get(e.corrects) || {};
114
+ out.push({ at: e.at, id: e.rid, op: 'add', fields: shape(e.rid, snap, e.at), by: e.by, why: e.why || 'restored' });
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /** Jira tickets of the initiative → events with no history: present at every check (`synthetic`). */
121
+ function jiraEvents(items, initiative, today) {
122
+ const out = [];
123
+ for (const j of items || []) {
124
+ if (j.source !== 'jira' || j.initiative !== initiative) continue;
125
+ const due = j.due || today;
126
+ out.push({ at: FAR_PAST, synthetic: true, id: j.key, op: 'add', by: 'Jira', why: '',
127
+ fields: { title: j.title, start: due, due, after: [], owner: null, points: undefined, state: j.category === 'done' ? 'done' : j.category === 'doing' ? 'doing' : 'todo',
128
+ origin: 'jira', src: 'Jira', key: j.key, url: j.url || null, status: j.status || null, undated: !j.due } });
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /** The whole answer for one initiative from already-read inputs (pure: the tests call it with fixtures). */
134
+ function buildOne({ planLines, planLive, breakdownLines, jira, initiative, today, checks }) {
135
+ const ev = [...planEvents(planLines, planLive, initiative), ...breakdownEvents(breakdownLines, initiative), ...jiraEvents(jira, initiative, today)];
136
+ const cks = checks || F.pickChecks(ev, today, 6);
137
+ const frames = F.checkFrames(ev, cks, today);
138
+ const count = (o) => ev.filter((e) => e.op === 'add' && e.fields.origin === o).length;
139
+ const notes = [];
140
+ if (count('jira')) notes.push('Jira keeps no history here: its tickets are drawn on their due day at every check.');
141
+ if (count('breakdown')) notes.push('Breakdown items have no dates: each starts the day it was added and lasts one day per point.');
142
+ return { events: ev, checks: cks, frames, sources: { plan: count('plan'), breakdown: count('breakdown'), jira: count('jira') }, notes };
143
+ }
144
+
145
+ function readInputs(w, ctx, now) {
146
+ const fold = planItems.readFold(w.root, { now });
147
+ const live = new Map(Object.values(fold.items).filter((it) => !it.proposed).map((it) => [it.id, it]));
148
+ const lines = planItems.readLines(w.root).lines;
149
+ let bd = [];
150
+ try { bd = breakdown.readLines(roadmapOps.rootOf(ctx)); } catch { bd = []; }
151
+ let jira = [];
152
+ try { jira = require('./routes-my-work.js').listFor(ctx, now).items.filter((i) => i.source === 'jira'); } catch { jira = []; }
153
+ return { planLines: lines, planLive: live, breakdownLines: bd, jira };
154
+ }
155
+
156
+ function build(w, ctx, q, now) {
157
+ const today = localYmd(new Date(now));
158
+ const inp = readInputs(w, ctx, now);
159
+ if (q.initiative) {
160
+ const r = buildOne({ ...inp, initiative: q.initiative, today });
161
+ return { ok: true, initiative: q.initiative, today, checks: r.checks, frames: r.frames, sources: r.sources, notes: r.notes };
162
+ }
163
+ const ids = String(q.initiatives || '').split(',').map((s) => s.trim()).filter(Boolean).slice(0, 40);
164
+ const per = ids.map((id) => ({ id, ...buildOne({ ...inp, initiative: id, today }) }));
165
+ const dates = [...new Set(per.flatMap((p) => p.events.filter((e) => !e.synthetic).map((e) => String(e.at).slice(0, 10))))].sort();
166
+ const checks = F.pickChecks(dates.map((d) => ({ at: d + 'T00:00:00Z', id: d, op: 'set', fields: {} })), today, 6);
167
+ const rows = per.map((p) => {
168
+ const fr = F.checkFrames(p.events, checks, today);
169
+ return { id: p.id, cells: fr.map((f) => (f.items.length ? { date: f.date, forecast_end: f.forecast_end, promised: f.promised, slip_days: f.slip_days } : null)) };
170
+ });
171
+ return { ok: true, today, checks, rows };
172
+ }
173
+
174
+ module.exports = {
175
+ prefixes: ['/api/c3-timeline'],
176
+ planEvents, breakdownEvents, jiraEvents, buildOne, build,
177
+ handle: async (req, res, url, ctx) => {
178
+ if (url.pathname !== '/api/c3-timeline') return false;
179
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only: the timeline is derived, and plan changes go through the plan-items writer' }, 405); return true; }
180
+ const initiative = String(url.searchParams.get('initiative') || '');
181
+ const initiatives = String(url.searchParams.get('initiatives') || '');
182
+ if (!initiative && !initiatives) { ctx.json(res, { ok: false, error: 'initiative or initiatives is required' }, 400); return true; }
183
+ const w = ctx.getWs(String(url.searchParams.get('ws') || ''));
184
+ if (!w || !w.root) { ctx.json(res, { ok: false, error: 'no such workspace' }, 404); return true; }
185
+ let out;
186
+ try { out = build(w, ctx, { initiative, initiatives }, Date.now()); }
187
+ catch (e) { out = { ok: false, status: 500, error: 'the timeline could not be read: ' + String((e && e.message) || 'error').slice(0, 160) }; }
188
+ ctx.json(res, out, out.ok ? 200 : out.status || 500);
189
+ return true;
190
+ },
191
+ };
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+ /* routes-calendar-import.js — cached calendar events, supplied by a session (console3 wave E2a).
3
+ *
4
+ * GET /api/calendar-import/events → { ok, events } the folded cache
5
+ * POST /api/calendar-import/events { events, sensitivity } → { ok, count } page press; sensitivity is required
6
+ * POST /api/calendar-import/note { event_id, text } → { ok } the PM's note on an event
7
+ *
8
+ * Nothing here fetches a calendar: PMOS holds no credentials. Events come in the body. Imports are untrusted data
9
+ * (scripts/calendar-import.js through scripts/trust/import-guard.js). Writes pass pm-guard requirePagePress, so an agent
10
+ * token is refused (403). readEvents(runtimeDir) is for routes-calendar-views.js.
11
+ * File: <runtime>/calendar-import/events.jsonl (append only, scripts/trust/ledger.js). */
12
+ const path = require('path');
13
+ const guard = require('./pm-guard.js');
14
+ const CI = require('../../scripts/calendar-import.js');
15
+ const L = require('../../scripts/trust/ledger.js');
16
+
17
+ const fileOf = (runtimeDir) => path.join(runtimeDir, 'calendar-import', 'events.jsonl');
18
+ const fail = (status, error) => ({ status, body: { ok: false, error } });
19
+
20
+ function readEvents(runtimeDir) { return CI.foldEvents(CI.readLines(fileOf(runtimeDir))); }
21
+
22
+ function list(runtimeDir) { return { status: 200, body: { ok: true, events: readEvents(runtimeDir) } }; }
23
+
24
+ function importRoute(req, body, runtimeDir, now = new Date()) {
25
+ const g = guard.requirePagePress(req, body);
26
+ if (!g.ok) return fail(g.status, g.error);
27
+ let evs;
28
+ try { evs = CI.importEvents(body.events, { fetchedAt: now.toISOString(), sensitivity: body.sensitivity }); }
29
+ catch (e) { return fail(400, e.message); }
30
+ try { CI.appendImport(fileOf(runtimeDir), evs, L.actorFrom({ ui: true }), now.toISOString()); }
31
+ catch (e) { return fail(500, e.message); }
32
+ return { status: 200, body: { ok: true, count: evs.length } };
33
+ }
34
+
35
+ function noteRoute(req, body, runtimeDir, now = new Date()) {
36
+ const g = guard.requirePagePress(req, body);
37
+ if (!g.ok) return fail(g.status, g.error);
38
+ if (!readEvents(runtimeDir).some((e) => e.id === body.event_id)) return fail(404, 'no such event');
39
+ try { CI.appendNote(fileOf(runtimeDir), body.event_id, body.text, L.actorFrom({ ui: true }), now.toISOString()); }
40
+ catch (e) { return fail(400, e.message); }
41
+ return { status: 200, body: { ok: true } };
42
+ }
43
+
44
+ const ROUTES = { 'POST /api/calendar-import/events': importRoute, 'POST /api/calendar-import/note': noteRoute };
45
+
46
+ module.exports = {
47
+ prefixes: ['/api/calendar-import'],
48
+ readEvents, list, importRoute, noteRoute, ROUTES, fileOf,
49
+ handle: async (req, res, url, ctx) => {
50
+ const p = url.pathname;
51
+ if (p === '/api/calendar-import/events' && req.method === 'GET') { const r = list(ctx.runtimeDir); ctx.json(res, r.body, r.status); return true; }
52
+ const fn = ROUTES[`${req.method} ${p}`];
53
+ if (!fn) return false;
54
+ const got = await ctx.readJson(req);
55
+ if (!got.ok) { ctx.json(res, { ok: false, error: 'invalid JSON body' }, 400); return true; }
56
+ const r = fn(req, got.body || {}, ctx.runtimeDir);
57
+ ctx.json(res, r.body, r.status);
58
+ return true;
59
+ },
60
+ };
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+ /* routes-calendar-views.js — GET /api/calendar-views?view=day|week|month|quarter|year&date=YYYY-MM-DD&ws=<id> (console3 wave C2).
3
+ *
4
+ * → { ok, view, range, cells, unit, gantt, strategy, events_source }
5
+ *
6
+ * READ ONLY. Initiatives come from the signal engine's repo reader (scripts/signals/facts.js readRepoFacts), required inside
7
+ * the handler so a failure answers 500 { ok:false } and never reaches the server. The date and the plan come from the records:
8
+ * the planned end is the promised date, the planned start is that date minus planned_days; no forecast is invented, so an
9
+ * initiative without one shows as 'no plan' (or 'overdue' past its promise).
10
+ *
11
+ * EVENTS: lib/calendar-memory.js has validate, merge and month functions only; no reader of the stored calendar is exported.
12
+ * Until one is, events = [] and events_source is 'none'. KRS: no KR metric series is stored in the repo, so the strategy lens
13
+ * gets none and says so. */
14
+ const path = require('path');
15
+ const model = require('../../scripts/calendar-model.js');
16
+
17
+ const today = () => { const d = new Date(); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; };
18
+
19
+ /** The pure handler. deps: { readFacts(root), readEvents?(range), readKrs?(), today } so tests need no repo. */
20
+ function handle(query, deps = {}) {
21
+ const view = (query && query.view) || 'month';
22
+ const date = (query && query.date) || (deps.today || today());
23
+ if (!model.VIEWS.includes(view)) return { status: 400, body: { ok: false, error: 'view must be one of ' + model.VIEWS.join(', ') } };
24
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date + 'T00:00:00Z'))) return { status: 400, body: { ok: false, error: 'date must be YYYY-MM-DD' } };
25
+ try {
26
+ const facts = deps.readFacts ? deps.readFacts() : { inis: [] };
27
+ const inis = facts.inis || [];
28
+ const range = model.rangeFor(view, date);
29
+ let events = []; let source = 'none';
30
+ if (typeof deps.readEvents === 'function') { events = deps.readEvents(range) || []; source = 'calendar-memory'; }
31
+ const krs = typeof deps.readKrs === 'function' ? deps.readKrs() : [];
32
+ const b = model.bucketEvents(events, view, date);
33
+ const nowDay = deps.today || today();
34
+ const gantt = model.ganttRows(inis, view, date, { today: nowDay });
35
+ const strategy = view === 'quarter' || view === 'year' ? model.strategyLens(inis, krs, nowDay) : null;
36
+ return { status: 200, body: { ok: true, view, range, unit: b.unit, cells: b.cells, gantt, strategy, events_source: source, today: nowDay } };
37
+ } catch (e) {
38
+ return { status: 500, body: { ok: false, error: String(e && e.message || e) } };
39
+ }
40
+ }
41
+
42
+ module.exports = {
43
+ prefixes: ['/api/calendar-views'],
44
+ handlePure: handle,
45
+ handle: async (req, res, url, ctx) => {
46
+ if (url.pathname !== '/api/calendar-views') return false;
47
+ if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
48
+ const w = ctx.getWs(url.searchParams.get('ws'));
49
+ if (!w) { ctx.json(res, { ok: false, error: 'no workspace is registered' }, 404); return true; }
50
+ const root = path.resolve(w.root);
51
+ let facts;
52
+ try { facts = await require('./facts-async.js').readFactsAsync(root); }
53
+ catch (e) { ctx.json(res, { ok: false, error: 'the calendar could not read the repository: ' + (e && e.message ? e.message : String(e)) }, 500); return true; }
54
+ const out = handle({ view: url.searchParams.get('view') || undefined, date: url.searchParams.get('date') || undefined }, {
55
+ readFacts: () => facts,
56
+ readEvents: () => require('./routes-calendar-import.js').readEvents(ctx.runtimeDir),
57
+ });
58
+ ctx.json(res, out.body, out.status);
59
+ return true;
60
+ },
61
+ };
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+ /* routes-doc-health.js — console3 wave B2: document health, read side.
3
+ *
4
+ * POST /api/doc-health/check { path, ws?, context? } → { ok, path, health } health = scripts/doc-health.js healthOf
5
+ *
6
+ * A read: nothing is written and no page press is needed. The file is read from the repository (or the named workspace's
7
+ * root) and refused when it resolves outside that root. From git it derives: the last change of the file, the last change
8
+ * of every relative Markdown link it cites, and the last change of each ADR a `D<n>` id in its text names. Everything
9
+ * else (requiredHeadings, data, readers, current, staleAfterDays) comes only from `context` in the body; a check without
10
+ * its input answers "not enough input". No write routes in this wave.
11
+ */
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { spawnSync } = require('child_process');
15
+ const H = require('../../scripts/doc-health.js');
16
+
17
+ const ROOT = path.resolve(__dirname, '..', '..');
18
+ const bad = (error, status = 400) => ({ status, body: { ok: false, error } });
19
+
20
+ function inside(root, full) {
21
+ try {
22
+ const r = fs.realpathSync(root), f = fs.realpathSync(full);
23
+ return f === r || f.startsWith(r + path.sep);
24
+ } catch { return false; }
25
+ }
26
+ function gitChanged(root, rel) {
27
+ const r = spawnSync('git', ['log', '-1', '--format=%cI', '--', rel], { cwd: root, encoding: 'utf8' });
28
+ return r.status === 0 && r.stdout.trim() ? r.stdout.trim() : null;
29
+ }
30
+ function adrFor(root, id) {
31
+ const dir = path.join(root, 'okf/products/pmos/adr');
32
+ const n = String(id).slice(1);
33
+ try { return fs.readdirSync(dir).find((f) => new RegExp('^d0*' + n + '-', 'i').test(f)) || null; } catch { return null; }
34
+ }
35
+
36
+ function check(root, body) {
37
+ const rel = typeof body.path === 'string' ? body.path : '';
38
+ if (!rel) return bad('path is required');
39
+ const full = path.resolve(root, rel);
40
+ if (!inside(root, path.dirname(full)) || !full.startsWith(path.resolve(root) + path.sep)) return bad('outside workspace', 404);
41
+ let text;
42
+ try { if (!inside(root, full)) return bad('outside workspace', 404); text = fs.readFileSync(full, 'utf8'); } catch { return bad('file not found', 404); }
43
+ const relOut = path.relative(root, full);
44
+ const changed = gitChanged(root, relOut);
45
+ const sources = [];
46
+ for (const m of text.matchAll(/\]\(([^)#\s]+\.md)(?:#[^)]*)?\)/g)) {
47
+ if (/^[a-z]+:/i.test(m[1])) continue;
48
+ const sp = m[1].startsWith('/') ? m[1].slice(1) : path.relative(root, path.resolve(path.dirname(full), m[1]));
49
+ if (sp.startsWith('..') || sources.some((s) => s.path === sp)) continue;
50
+ sources.push({ path: sp, changed: gitChanged(root, sp) });
51
+ }
52
+ const decisionChanges = {};
53
+ for (const id of new Set(text.match(/\bD\d{1,3}\b/g) || [])) {
54
+ const f = adrFor(root, id);
55
+ const at = f && gitChanged(root, 'okf/products/pmos/adr/' + f);
56
+ if (at) decisionChanges[id] = at;
57
+ }
58
+ const given = body.context && typeof body.context === 'object' ? body.context : {};
59
+ const context = { ...given, sources: sources.length ? sources : given.sources,
60
+ decisionChanges: Object.keys(decisionChanges).length ? decisionChanges : given.decisionChanges };
61
+ const doc = { text, type: given.type || body.type, changed };
62
+ return { status: 200, body: { ok: true, path: relOut, health: H.healthOf(doc, context) } };
63
+ }
64
+
65
+ module.exports = {
66
+ prefixes: ['/api/doc-health'],
67
+ check,
68
+ handle: async (req, res, url, ctx) => {
69
+ if (req.method !== 'POST' || url.pathname !== '/api/doc-health/check') return false;
70
+ const got = await ctx.readJson(req);
71
+ if (!got.ok) { ctx.json(res, { ok: false, error: 'invalid JSON body' }, 400); return true; }
72
+ const body = got.body || {};
73
+ const w = body.ws && ctx.getWs ? ctx.getWs(body.ws) : null;
74
+ if (body.ws && !w) { ctx.json(res, { ok: false, error: 'no workspace' }, 404); return true; }
75
+ const r = check(w ? w.root : (ctx.root || ROOT), body);
76
+ ctx.json(res, r.body, r.status);
77
+ return true;
78
+ },
79
+ };