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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/nine-stages.js +81 -0
- package/payload/scripts/workflow-state.js +2 -0
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/c3-one-home-test.js +131 -0
- package/workspace/fixtures/initiatives-api/list.json +171 -3
- package/workspace/fixtures/initiatives-api/roadmap.json +228 -4
- package/workspace/fixtures/initiatives-api/sheet.json +57 -1
- package/workspace/fixtures/one-home-baseline.json +83 -0
- package/workspace/guide/layout.md +2 -0
- package/workspace/guide/notes.md +1 -0
- package/workspace/lib/engine/nine-stages.js +81 -0
- package/workspace/lib/engine/workflow-state.js +2 -0
- package/workspace/lib/facts-async.js +51 -0
- package/workspace/lib/item-steps.js +52 -0
- package/workspace/lib/jira-status.js +8 -3
- package/workspace/lib/jobs-to-be-done.js +52 -0
- package/workspace/lib/outbox-store.js +87 -0
- package/workspace/lib/pm-items.js +115 -0
- package/workspace/lib/route-modules.js +21 -0
- package/workspace/lib/routes-briefing.js +100 -0
- package/workspace/lib/routes-c3-init.js +53 -0
- package/workspace/lib/routes-c3-life.js +119 -0
- package/workspace/lib/routes-c3-strategy.js +169 -0
- package/workspace/lib/routes-c3-timeline.js +191 -0
- package/workspace/lib/routes-calendar-import.js +60 -0
- package/workspace/lib/routes-calendar-views.js +61 -0
- package/workspace/lib/routes-doc-health.js +79 -0
- package/workspace/lib/routes-doc-sheet.js +169 -0
- package/workspace/lib/routes-forecast.js +85 -0
- package/workspace/lib/routes-hooks.js +121 -0
- package/workspace/lib/routes-initiatives.js +3 -0
- package/workspace/lib/routes-intake.js +24 -0
- package/workspace/lib/routes-outbox.js +97 -0
- package/workspace/lib/routes-pm-items.js +216 -0
- package/workspace/lib/routes-records.js +61 -0
- package/workspace/lib/routes-releases.js +129 -0
- package/workspace/lib/routes-report-facts.js +84 -0
- package/workspace/lib/routes-report-families.js +32 -0
- package/workspace/lib/routes-roadmap-ops.js +141 -0
- package/workspace/lib/routes-runs.js +119 -0
- package/workspace/lib/routes-signals.js +50 -0
- package/workspace/lib/run-answers.js +69 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/c3.html +26 -0
- package/workspace/public/pm/briefing-deck.js +415 -0
- package/workspace/public/pm/briefing-model.js +176 -0
- package/workspace/public/pm/c3-breakdown.js +240 -0
- package/workspace/public/pm/c3-brief.js +278 -0
- package/workspace/public/pm/c3-cal-strategy.js +528 -0
- package/workspace/public/pm/c3-case.js +173 -0
- package/workspace/public/pm/c3-demo-brief-ix.js +350 -0
- package/workspace/public/pm/c3-demo-cal-ix.js +74 -0
- package/workspace/public/pm/c3-demo-cal2.js +66 -0
- package/workspace/public/pm/c3-demo-cal3.js +173 -0
- package/workspace/public/pm/c3-demo-data.js +560 -0
- package/workspace/public/pm/c3-demo-init.js +373 -0
- package/workspace/public/pm/c3-demo-life.js +77 -0
- package/workspace/public/pm/c3-demo-misc2.js +46 -0
- package/workspace/public/pm/c3-demo-plan2.js +30 -0
- package/workspace/public/pm/c3-demo-rep-ix.js +126 -0
- package/workspace/public/pm/c3-demo-rep2.js +92 -0
- package/workspace/public/pm/c3-demo-term.js +153 -0
- package/workspace/public/pm/c3-demo-time.js +49 -0
- package/workspace/public/pm/c3-demo-work-ix.js +80 -0
- package/workspace/public/pm/c3-demo-work2.js +13 -0
- package/workspace/public/pm/c3-demo.js +113 -0
- package/workspace/public/pm/c3-doc-sheet.js +314 -0
- package/workspace/public/pm/c3-dock.js +180 -0
- package/workspace/public/pm/c3-docs.js +290 -0
- package/workspace/public/pm/c3-expand.js +96 -0
- package/workspace/public/pm/c3-init-extras.js +170 -0
- package/workspace/public/pm/c3-lifecycle-panels.js +476 -0
- package/workspace/public/pm/c3-misc-panels.js +89 -0
- package/workspace/public/pm/c3-plan-panels.js +163 -0
- package/workspace/public/pm/c3-plan.js +192 -0
- package/workspace/public/pm/c3-roadmap.js +249 -0
- package/workspace/public/pm/c3-sheet.js +107 -0
- package/workspace/public/pm/c3-shell.js +637 -0
- package/workspace/public/pm/c3-signals.js +196 -0
- package/workspace/public/pm/c3-strategy-panels.js +45 -0
- package/workspace/public/pm/c3-swipe.js +138 -0
- package/workspace/public/pm/c3-terminal-data.js +26 -0
- package/workspace/public/pm/c3-terminal-edge.js +91 -0
- package/workspace/public/pm/c3-terminal-icons.js +41 -0
- package/workspace/public/pm/c3-terminal-results.js +141 -0
- package/workspace/public/pm/c3-terminal.js +52 -0
- package/workspace/public/pm/c3-tickets.js +163 -0
- package/workspace/public/pm/c3-timeline.js +201 -0
- package/workspace/public/pm/c3-toast.js +16 -0
- package/workspace/public/pm/calendar-views.js +1015 -0
- package/workspace/public/pm/console3-lift.md +220 -0
- package/workspace/public/pm/console3.css +1480 -0
- package/workspace/public/pm/dest-initiatives.js +16 -3
- package/workspace/public/pm/dest-mywork.js +8 -1
- package/workspace/public/pm/dest-notes.js +25 -3
- package/workspace/public/pm/dest-reports.js +47 -1
- package/workspace/public/pm/dest-today.js +55 -1
- package/workspace/public/pm/doc-health-panel.js +110 -0
- package/workspace/public/pm/doc-rail.js +9 -3
- package/workspace/public/pm/lifecycle-lens-model.js +228 -0
- package/workspace/public/pm/lifecycle-lens.js +261 -0
- package/workspace/public/pm/new-initiative-sheet.js +116 -21
- package/workspace/public/pm/outbox-panel.js +125 -0
- package/workspace/public/pm/pm-work-view.js +919 -0
- package/workspace/public/pm/releases-view.js +248 -0
- package/workspace/public/pm/report-families-view.js +503 -0
- package/workspace/public/pm/run-card.js +244 -0
- package/workspace/public/pm/sessions-panel.js +149 -0
- package/workspace/public/pm/shell-bell.js +162 -0
- package/workspace/public/pm/shell-model.js +83 -0
- package/workspace/public/pm/shell-phone.js +124 -0
- package/workspace/public/pm/shell-readaloud.js +53 -0
- package/workspace/public/pm/shell-recording.js +110 -0
- package/workspace/public/pm/shell-ticker.js +90 -0
- package/workspace/public/pm/slide-confirm.js +163 -0
- package/workspace/public/pm/timeline-forecast-view.js +164 -0
- package/workspace/public/pm/today-day.js +8 -2
- package/workspace/public/pm/tokens.css +1 -0
- package/workspace/server.js +5 -2
- package/workspace/test-files.json +8 -0
- package/workspace/test-lists/browser-suites-green.json +5 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-pm-items.js — the PM's own work items and jobs to be done (planning-flows run 1 and 2, UI slice).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/pm-items → { ok, today, lists: { waiting, today, week, triage, later }, jobs, items }
|
|
5
|
+
* POST /api/pm-items/add { line } → { ok, item } one line: the parser (lib/pm-items.js) reads the parts
|
|
6
|
+
* POST /api/pm-items/edit { id, fields }→ { ok, item } fields: title, due, priority, waiting_on, job
|
|
7
|
+
* POST /api/pm-items/remove { id } → { ok, undo_ms } deleted (created here) or dropped (imported)
|
|
8
|
+
* POST /api/pm-items/undo { id } → { ok, item } | 409 restores the last removal inside the 8 s window
|
|
9
|
+
* POST /api/pm-items/done { id } → { ok } folds to item.done + done_at; /reopen undoes it
|
|
10
|
+
* POST /api/pm-items/park { id } → { ok } parked: true, placed in Later; /unpark clears it
|
|
11
|
+
* POST /api/pm-items/move { id, list } → { ok } list_override, one of the five lists
|
|
12
|
+
* POST /api/pm-items/steps/add { id, text, who? } → { ok, step } outward steps are forced to agent-drafts
|
|
13
|
+
* POST /api/pm-items/steps/who { step_id, who } → { ok, who }
|
|
14
|
+
* POST /api/pm-items/steps/plan { id } → { ok, step } one PM-only review step for now
|
|
15
|
+
* POST /api/pm-jobs/add { line } → { ok, job } one line: "when …, I want …, so …" (lib/jobs-to-be-done.js)
|
|
16
|
+
* POST /api/pm-jobs/edit { id, fields }→ { ok, job } fields: when, want, so
|
|
17
|
+
* POST /api/pm-jobs/delete { id, reassign_to? } → { ok, reassigned } items move to reassign_to, or to none
|
|
18
|
+
*
|
|
19
|
+
* STATE. Two append-only files under <runtime>/pm-items/: items.jsonl and jobs.jsonl. Every write is one appended line
|
|
20
|
+
* through pm-items.appendEvent, the single writer, and the GET folds the whole file again. Nothing is read from Jira.
|
|
21
|
+
*
|
|
22
|
+
* WHO WRITES. Every POST passes pm-guard requirePagePress: the page only, with the Origin and nonce checks. An agent
|
|
23
|
+
* session cannot add, edit or remove the PM's items; that is the boundary the PRD sets for run 1.
|
|
24
|
+
*/
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
const guard = require('./pm-guard.js');
|
|
29
|
+
const pmItems = require('./pm-items.js');
|
|
30
|
+
const jobs = require('./jobs-to-be-done.js');
|
|
31
|
+
const steps = require('./item-steps.js');
|
|
32
|
+
|
|
33
|
+
const FIELDS = ['title', 'due', 'priority', 'waiting_on', 'job'];
|
|
34
|
+
const JOB_FIELDS = ['when', 'want', 'so'];
|
|
35
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
36
|
+
|
|
37
|
+
const dirOf = (ctx) => path.join(ctx.runtimeDir, 'pm-items');
|
|
38
|
+
const itemsFile = (ctx) => path.join(dirOf(ctx), 'items.jsonl');
|
|
39
|
+
const stepsFile = (ctx) => path.join(dirOf(ctx), 'steps.jsonl');
|
|
40
|
+
const jobsFile = (ctx) => path.join(dirOf(ctx), 'jobs.jsonl');
|
|
41
|
+
const newId = (prefix) => `${prefix}-${crypto.randomBytes(6).toString('hex')}`;
|
|
42
|
+
|
|
43
|
+
// The current view, folded from the files. `now` decides placement, so tests can pin the day.
|
|
44
|
+
function view(itemLines, jobLines, now, stepLines = []) {
|
|
45
|
+
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
46
|
+
const byItem = steps.foldSteps(stepLines).byItem;
|
|
47
|
+
const items = pmItems.fold(itemLines).map((i) => ({ ...i, steps: byItem[i.id] || [] }));
|
|
48
|
+
const currentJobs = jobs.foldJobs(jobLines).map((j) => ({ ...j, ...jobs.progress(j.id, items) }));
|
|
49
|
+
const lists = Object.fromEntries(pmItems.LISTS.map((l) => [l, []]));
|
|
50
|
+
for (const it of items) lists[pmItems.placement(it, today)].push(it);
|
|
51
|
+
return { ok: true, today: today.toISOString().slice(0, 10), lists, jobs: currentJobs, items };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Each operation returns { status, body, events } and writes nothing itself; the caller appends `events`.
|
|
55
|
+
function checkFields(fields, allowed) {
|
|
56
|
+
if (!fields || typeof fields !== 'object') return 'fields must be an object';
|
|
57
|
+
for (const k of Object.keys(fields)) {
|
|
58
|
+
if (!allowed.includes(k)) return `${k} cannot be set here`;
|
|
59
|
+
}
|
|
60
|
+
if ('due' in fields && fields.due !== null && !DATE_RE.test(String(fields.due))) return 'due must be YYYY-MM-DD or null';
|
|
61
|
+
if ('priority' in fields && fields.priority !== null && !/^P[1-3]$/.test(String(fields.priority))) return 'priority must be P1, P2, P3 or null';
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function addItem(line, now) {
|
|
66
|
+
const parsed = pmItems.parseLine(line, now);
|
|
67
|
+
if (!parsed.title) return { status: 400, body: { ok: false, error: 'the line needs a title' } };
|
|
68
|
+
const id = newId('item');
|
|
69
|
+
const fields = { title: parsed.title, due: parsed.due, priority: parsed.priority, waiting_on: parsed.waiting_on, job: null };
|
|
70
|
+
return { status: 200, body: { ok: true, item: { id, ...fields, source: 'local' } }, events: [{ event: 'created', id, fields, source: 'local' }] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function editItem(body, itemLines) {
|
|
74
|
+
const err = checkFields(body.fields, FIELDS);
|
|
75
|
+
if (err) return { status: 400, body: { ok: false, error: err } };
|
|
76
|
+
const item = pmItems.fold(itemLines).find((i) => i.id === body.id);
|
|
77
|
+
if (!item) return { status: 404, body: { ok: false, error: 'no such item' } };
|
|
78
|
+
return { status: 200, body: { ok: true, item: { ...item, ...body.fields } }, events: [{ event: 'edited', id: body.id, fields: body.fields }] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function removeItem(body, itemLines, now) {
|
|
82
|
+
const item = pmItems.fold(itemLines).find((i) => i.id === body.id);
|
|
83
|
+
if (!item) return { status: 404, body: { ok: false, error: 'no such item' } };
|
|
84
|
+
return { status: 200, body: { ok: true, undo_ms: pmItems.UNDO_MS }, events: [pmItems.removalEvent(item, now)] };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function undoItem(body, itemLines, now) {
|
|
88
|
+
const restore = pmItems.undoEvent(itemLines, body.id, now);
|
|
89
|
+
if (!restore) return { status: 409, body: { ok: false, error: 'nothing to undo for this item (the window is 8 seconds)' } };
|
|
90
|
+
return { status: 200, body: { ok: true, item: { id: body.id, ...restore.fields, source: restore.source } }, events: [restore] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const findItem = (itemLines, id) => pmItems.fold(itemLines).find((i) => i.id === id);
|
|
94
|
+
const noItem = { status: 404, body: { ok: false, error: 'no such item' } };
|
|
95
|
+
|
|
96
|
+
function flagItem(body, itemLines, events) {
|
|
97
|
+
const item = findItem(itemLines, body.id);
|
|
98
|
+
if (!item) return noItem;
|
|
99
|
+
return { status: 200, body: { ok: true }, events: typeof events === 'function' ? events(item) : events };
|
|
100
|
+
}
|
|
101
|
+
const doneItem = (b, l) => flagItem(b, l, [{ event: 'done', id: b.id }]);
|
|
102
|
+
const reopenItem = (b, l) => flagItem(b, l, [{ event: 'reopened', id: b.id }]);
|
|
103
|
+
const parkItem = (b, l) => flagItem(b, l, [{ event: 'edited', id: b.id, fields: { parked: true, list_override: null } }]);
|
|
104
|
+
const unparkItem = (b, l) => flagItem(b, l, [{ event: 'edited', id: b.id, fields: { parked: false } }]);
|
|
105
|
+
function moveItem(b, l) {
|
|
106
|
+
if (!pmItems.LISTS.includes(b.list)) return { status: 400, body: { ok: false, error: 'list must be one of ' + pmItems.LISTS.join(', ') } };
|
|
107
|
+
return flagItem(b, l, [{ event: 'edited', id: b.id, fields: { list_override: b.list, parked: false } }]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function addStepRoute(b, itemLines) {
|
|
111
|
+
if (!findItem(itemLines, b.id)) return noItem;
|
|
112
|
+
const id = newId('step');
|
|
113
|
+
const r = steps.addStep(b.id, b.text, b.who, id);
|
|
114
|
+
if (!r.ok) return { status: 400, body: r };
|
|
115
|
+
return { status: 200, body: { ok: true, step: { id, text: r.event.text, who: r.event.who }, forced: r.forced }, events: [r.event] };
|
|
116
|
+
}
|
|
117
|
+
function stepWho(b, stepLines) {
|
|
118
|
+
const r = steps.setWho(stepLines, b.step_id, b.who);
|
|
119
|
+
if (!r.ok) return { status: r.error === 'no such step' ? 404 : 400, body: r };
|
|
120
|
+
return { status: 200, body: { ok: true, who: r.who, forced: r.forced }, events: [r.event] };
|
|
121
|
+
}
|
|
122
|
+
// PLACEHOLDER. Agent planning of steps needs the run 7 reply path (an agent session answering through the reply
|
|
123
|
+
// verb). Until then this makes ONE step, 'Review: <title>', for the PM only, and only when the PM calls it.
|
|
124
|
+
function planSteps(b, itemLines) {
|
|
125
|
+
const item = findItem(itemLines, b.id);
|
|
126
|
+
if (!item) return noItem;
|
|
127
|
+
// With an agent's reply (from the reply path), parse it: a step that sends something to a person is forced to
|
|
128
|
+
// agent-drafts by the planner. Without one, fall back to the single review step for the PM.
|
|
129
|
+
if (b.reply !== undefined) {
|
|
130
|
+
const plan = require('../../scripts/step-planner.js').parsePlanReply(b.reply);
|
|
131
|
+
if (!plan.ok) return { status: 400, body: { ok: false, error: plan.error } };
|
|
132
|
+
const added = plan.steps.map((st) => steps.addStep(b.id, st.text, st.who, newId('step')));
|
|
133
|
+
return { status: 200, body: { ok: true, steps: added.map((r) => ({ id: r.event.id, text: r.event.text, who: r.event.who })) }, events: added.map((r) => r.event) };
|
|
134
|
+
}
|
|
135
|
+
const id = newId('step');
|
|
136
|
+
const r = steps.addStep(b.id, 'Review: ' + item.title, 'you', id);
|
|
137
|
+
return { status: 200, body: { ok: true, step: { id, text: r.event.text, who: 'you' } }, events: [r.event] };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function addJob(line) {
|
|
141
|
+
const d = jobs.draftJob(line);
|
|
142
|
+
if (!d.when && !d.want && !d.so) return { status: 400, body: { ok: false, error: 'write the job as: when …, I want …, so …' } };
|
|
143
|
+
const id = newId('job');
|
|
144
|
+
return { status: 200, body: { ok: true, job: { id, ...d } }, events: [{ event: 'created', id, fields: d }] };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function editJob(body, jobLines) {
|
|
148
|
+
const err = checkFields(body.fields, JOB_FIELDS);
|
|
149
|
+
if (err) return { status: 400, body: { ok: false, error: err } };
|
|
150
|
+
if (!jobs.foldJobs(jobLines).find((j) => j.id === body.id)) return { status: 404, body: { ok: false, error: 'no such job' } };
|
|
151
|
+
return { status: 200, body: { ok: true }, events: [{ event: 'edited', id: body.id, fields: body.fields }] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function deleteJob(body, jobLines, itemLines, now) {
|
|
155
|
+
if (!jobs.foldJobs(jobLines).find((j) => j.id === body.id)) return { status: 404, body: { ok: false, error: 'no such job' } };
|
|
156
|
+
const reassignTo = body.reassign_to || null;
|
|
157
|
+
if (reassignTo && !jobs.foldJobs(jobLines).find((j) => j.id === reassignTo)) return { status: 400, body: { ok: false, error: 'reassign_to is not a job' } };
|
|
158
|
+
const r = jobs.deleteJob(body.id, pmItems.fold(itemLines), reassignTo, now);
|
|
159
|
+
// Items go to the new job through the item file, and the job's deletion goes to the job file.
|
|
160
|
+
// Each event names its own file: the job's deletion goes to jobs.jsonl, the item edits to items.jsonl.
|
|
161
|
+
const events = r.events.map((ev) => ({ ...ev, _file: ev.event === 'deleted' ? 'jobs' : 'items' }));
|
|
162
|
+
return { status: 200, body: { ok: true, reassigned: r.reassigned }, events };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const ROUTES = {
|
|
166
|
+
'POST /api/pm-items/add': (b, ctx) => ({ ...addItem(b.line, new Date()), file: 'items' }),
|
|
167
|
+
'POST /api/pm-items/edit': (b, ctx) => ({ ...editItem(b, ctx.itemLines), file: 'items' }),
|
|
168
|
+
'POST /api/pm-items/remove': (b, ctx) => ({ ...removeItem(b, ctx.itemLines, new Date()), file: 'items' }),
|
|
169
|
+
'POST /api/pm-items/undo': (b, ctx) => ({ ...undoItem(b, ctx.itemLines, new Date()), file: 'items' }),
|
|
170
|
+
'POST /api/pm-items/done': (b, ctx) => ({ ...doneItem(b, ctx.itemLines), file: 'items' }),
|
|
171
|
+
'POST /api/pm-items/reopen': (b, ctx) => ({ ...reopenItem(b, ctx.itemLines), file: 'items' }),
|
|
172
|
+
'POST /api/pm-items/park': (b, ctx) => ({ ...parkItem(b, ctx.itemLines), file: 'items' }),
|
|
173
|
+
'POST /api/pm-items/unpark': (b, ctx) => ({ ...unparkItem(b, ctx.itemLines), file: 'items' }),
|
|
174
|
+
'POST /api/pm-items/move': (b, ctx) => ({ ...moveItem(b, ctx.itemLines), file: 'items' }),
|
|
175
|
+
'POST /api/pm-items/steps/add': (b, ctx) => ({ ...addStepRoute(b, ctx.itemLines), file: 'steps' }),
|
|
176
|
+
'POST /api/pm-items/steps/who': (b, ctx) => ({ ...stepWho(b, ctx.stepLines), file: 'steps' }),
|
|
177
|
+
'POST /api/pm-items/steps/plan': (b, ctx) => ({ ...planSteps(b, ctx.itemLines), file: 'steps' }),
|
|
178
|
+
'POST /api/pm-jobs/add': (b) => ({ ...addJob(b.line), file: 'jobs' }),
|
|
179
|
+
'POST /api/pm-jobs/edit': (b, ctx) => ({ ...editJob(b, ctx.jobLines), file: 'jobs' }),
|
|
180
|
+
'POST /api/pm-jobs/delete': (b, ctx) => ({ ...deleteJob(b, ctx.jobLines, ctx.itemLines, new Date()), file: 'items' }),
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
function prepare(ctx, now = new Date()) {
|
|
184
|
+
return { itemLines: pmItems.readLines(itemsFile(ctx)), jobLines: pmItems.readLines(jobsFile(ctx)), stepLines: pmItems.readLines(stepsFile(ctx)), now };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
prefixes: ['/api/pm-items', '/api/pm-jobs'],
|
|
189
|
+
view, doneItem, reopenItem, parkItem, unparkItem, moveItem, addStepRoute, stepWho, planSteps, addItem, editItem, removeItem, undoItem, addJob, editJob, deleteJob, checkFields, ROUTES, prepare,
|
|
190
|
+
handle: async (req, res, url, ctx) => {
|
|
191
|
+
const p = url.pathname;
|
|
192
|
+
if (p === '/api/pm-items' && req.method === 'GET') {
|
|
193
|
+
const c = prepare(ctx);
|
|
194
|
+
ctx.json(res, view(c.itemLines, c.jobLines, c.now, c.stepLines));
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
const key = `${req.method} ${p}`;
|
|
198
|
+
if (!ROUTES[key]) return false;
|
|
199
|
+
const got = await ctx.readJson(req);
|
|
200
|
+
if (!got.ok) { ctx.json(res, { ok: false, error: 'invalid JSON body' }, 400); return true; }
|
|
201
|
+
const body = got.body || {};
|
|
202
|
+
const g = guard.requirePagePress(req, body);
|
|
203
|
+
if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
|
|
204
|
+
const c = prepare(ctx);
|
|
205
|
+
const r = ROUTES[key](body, c);
|
|
206
|
+
if (r.status === 200 && r.events) {
|
|
207
|
+
for (const ev of r.events) {
|
|
208
|
+
const which = ev._file || r.file;
|
|
209
|
+
const { _file, ...line } = ev;
|
|
210
|
+
pmItems.appendEvent(which === 'jobs' ? jobsFile(ctx) : which === 'steps' ? stepsFile(ctx) : itemsFile(ctx), line, c.now);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
ctx.json(res, r.body, r.status);
|
|
214
|
+
return true;
|
|
215
|
+
},
|
|
216
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-records.js — ideas, hypotheses, the decision log, KR metrics and bets (console3 wave E1a).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/records/<kind> → { ok, kind, records } the folded list
|
|
5
|
+
* POST /api/records/<kind>/add { fields, why? } → { ok, rid } kind: ideas | hypotheses | decisions | kr-metrics | bets
|
|
6
|
+
* POST /api/records/<kind>/edit { rid, fields, why? } → { ok }
|
|
7
|
+
* POST /api/records/kr-metrics/reading { rid, at, value } → { ok } one actual reading of a KR metric
|
|
8
|
+
*
|
|
9
|
+
* STATE: planning/records/<kind>.jsonl, written only through scripts/records (which uses scripts/trust/ledger.js).
|
|
10
|
+
* WHO WRITES: every POST passes pm-guard requirePagePress; an agent session cannot add or edit these records.
|
|
11
|
+
*/
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const guard = require('./pm-guard.js');
|
|
14
|
+
const records = require(path.join(__dirname, '..', '..', 'scripts', 'records', 'index.js'));
|
|
15
|
+
const ledger = require(path.join(__dirname, '..', '..', 'scripts', 'trust', 'ledger.js'));
|
|
16
|
+
|
|
17
|
+
const rootOf = (ctx) => (ctx && ctx.recordsDir) || path.join(ctx.repoRoot || path.join(__dirname, '..', '..'), 'planning', 'records');
|
|
18
|
+
const UI = () => ledger.actorFrom({ ui: true });
|
|
19
|
+
const bad = (status, error, reasons) => ({ status, body: { ok: false, error, reasons } });
|
|
20
|
+
|
|
21
|
+
/** Pure handlers: (root, body) -> { status, body }. The press check happens before these. */
|
|
22
|
+
function listRecords(root, kind) {
|
|
23
|
+
if (!records.kindNames().includes(kind)) return bad(404, 'unknown record kind: ' + kind);
|
|
24
|
+
return { status: 200, body: { ok: true, kind, records: records.list(root, kind) } };
|
|
25
|
+
}
|
|
26
|
+
function addRecord(root, kind, body) {
|
|
27
|
+
if (!records.kindNames().includes(kind)) return bad(404, 'unknown record kind: ' + kind);
|
|
28
|
+
const r = records.add(root, kind, (body && body.fields) || {}, UI(), body && body.why);
|
|
29
|
+
return r.ok ? { status: 200, body: { ok: true, rid: r.rid } } : bad(400, r.reasons.join('; '), r.reasons);
|
|
30
|
+
}
|
|
31
|
+
function editRecord(root, kind, body) {
|
|
32
|
+
if (!records.kindNames().includes(kind)) return bad(404, 'unknown record kind: ' + kind);
|
|
33
|
+
const r = records.edit(root, kind, body && body.rid, (body && body.fields) || {}, UI(), body && body.why);
|
|
34
|
+
return r.ok ? { status: 200, body: { ok: true } } : bad(r.reasons[0].startsWith('no such') ? 404 : 400, r.reasons.join('; '), r.reasons);
|
|
35
|
+
}
|
|
36
|
+
function readingRecord(root, body) {
|
|
37
|
+
const r = records.addReading(root, body && body.rid, { at: body && body.at, value: body && body.value }, UI(), body && body.why);
|
|
38
|
+
return r.ok ? { status: 200, body: { ok: true } } : bad(400, r.reasons.join('; '), r.reasons);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = {
|
|
42
|
+
prefixes: ['/api/records'],
|
|
43
|
+
listRecords, addRecord, editRecord, readingRecord, rootOf,
|
|
44
|
+
handle: async (req, res, url, ctx) => {
|
|
45
|
+
const m = url.pathname.match(/^\/api\/records\/([a-z-]+)(?:\/(add|edit|reading))?$/);
|
|
46
|
+
if (!m) return false;
|
|
47
|
+
const [, kind, verb] = m;
|
|
48
|
+
if (req.method === 'GET' && !verb) { const r = listRecords(rootOf(ctx), kind); ctx.json(res, r.body, r.status); return true; }
|
|
49
|
+
if (req.method !== 'POST' || !verb) return false;
|
|
50
|
+
const got = await ctx.readJson(req);
|
|
51
|
+
if (!got.ok) { ctx.json(res, { ok: false, error: 'invalid JSON body' }, 400); return true; }
|
|
52
|
+
const body = got.body || {};
|
|
53
|
+
const g = guard.requirePagePress(req, body);
|
|
54
|
+
if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
|
|
55
|
+
const r = verb === 'add' ? addRecord(rootOf(ctx), kind, body)
|
|
56
|
+
: verb === 'edit' ? editRecord(rootOf(ctx), kind, body)
|
|
57
|
+
: kind === 'kr-metrics' ? readingRecord(rootOf(ctx), body) : bad(404, 'readings belong to kr-metrics');
|
|
58
|
+
ctx.json(res, r.body, r.status);
|
|
59
|
+
return true;
|
|
60
|
+
},
|
|
61
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-releases.js — console3 wave A4: releases, read side.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/releases → { ok, releases, board } each release carries gapFlags, freezeWarning (null), tags
|
|
5
|
+
* POST /api/releases/create { initiative_ids, type, deploy_at, release_at } → { ok, release }
|
|
6
|
+
* POST /api/releases/edit { id, fields } fields: initiative_ids, type, deploy_at, release_at, fix_version, accepted
|
|
7
|
+
* POST /api/releases/delete { id } refused while the release has initiatives assigned
|
|
8
|
+
* POST /api/releases/summary { id, lines } lines: [{ text, source }]; stored only when every line is cited
|
|
9
|
+
*
|
|
10
|
+
* STATE. One append-only file, <runtime>/releases/releases.jsonl, folded on every read (scripts/releases.js).
|
|
11
|
+
* The Jira board name lives in <runtime>/releases/board.json ({ name, tags, map }); it is empty until the PM sets it.
|
|
12
|
+
* BOUNDARY. PMOS never reads Jira and holds no Jira credential: tickets come from the cached status snapshots an agent
|
|
13
|
+
* read saved (jira-status.js). A release record never writes a Jira tag. The tag needs the PM's acceptance first
|
|
14
|
+
* (the `accepted` field); creating the tag is not built. No calendar hold, no customer note: those are outbox actions.
|
|
15
|
+
* WHO WRITES. Every POST passes pm-guard requirePagePress.
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
const guard = require('./pm-guard.js');
|
|
21
|
+
const R = require('../../scripts/releases.js');
|
|
22
|
+
|
|
23
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
24
|
+
const dirOf = (ctx) => path.join(ctx.runtimeDir, 'releases');
|
|
25
|
+
const file = (ctx) => path.join(dirOf(ctx), 'releases.jsonl');
|
|
26
|
+
const newId = () => `rel-${crypto.randomBytes(5).toString('hex')}`;
|
|
27
|
+
const bad = (error, status = 400) => ({ status, body: { ok: false, error } });
|
|
28
|
+
|
|
29
|
+
function readLines(f) {
|
|
30
|
+
try { return fs.readFileSync(f, 'utf8').split('\n').filter(Boolean); } catch { return []; }
|
|
31
|
+
}
|
|
32
|
+
// The board setting. Empty by default: detection then answers 'board not set'. No naming pattern is ever guessed.
|
|
33
|
+
function readBoard(ctx) {
|
|
34
|
+
try {
|
|
35
|
+
const b = JSON.parse(fs.readFileSync(path.join(dirOf(ctx), 'board.json'), 'utf8'));
|
|
36
|
+
const name = typeof b.name === 'string' ? b.name.trim() : '';
|
|
37
|
+
return name ? { name, tags: Array.isArray(b.tags) ? b.tags.map(String) : [], map: b.map && typeof b.map === 'object' ? b.map : {} } : null;
|
|
38
|
+
} catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
// Tickets from the cached Jira snapshots (read-only, no Jira call). A reading saved before fixVersion was carried has no
|
|
41
|
+
// fixVersion list; it is left out, so it cannot raise a false gap flag.
|
|
42
|
+
function cachedTickets(root, jiraStatus) {
|
|
43
|
+
const out = [];
|
|
44
|
+
try {
|
|
45
|
+
for (const q of jiraStatus.linkedInitiatives(root).filter((x) => x.linked)) {
|
|
46
|
+
const snap = jiraStatus.readSnapshot(root, q.initiative);
|
|
47
|
+
if (!snap) continue;
|
|
48
|
+
for (const t of snap.tickets.values()) if (Array.isArray(t.fixVersion)) out.push({ ...t, initiative_id: q.initiative });
|
|
49
|
+
}
|
|
50
|
+
} catch { /* no snapshots yet */ }
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function view(lines, tickets, board, rule = null) {
|
|
55
|
+
const releases = R.foldReleases(lines).map((r) => ({
|
|
56
|
+
...r, gapFlags: R.gapFlags(r, tickets), freezeWarning: R.freezeWarning(r, rule), tags: R.detectTags(r, board),
|
|
57
|
+
}));
|
|
58
|
+
return { ok: true, releases, board: board ? { name: board.name } : null };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const isIdList = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string' && x);
|
|
62
|
+
|
|
63
|
+
// Each operation returns { status, body, events } and writes nothing itself.
|
|
64
|
+
function createRelease(body, id = newId()) {
|
|
65
|
+
if (body.initiative_ids !== undefined && !isIdList(body.initiative_ids)) return bad('initiative_ids must be a list of initiative ids');
|
|
66
|
+
let rel;
|
|
67
|
+
try { rel = R.newRelease({ id, initiative_ids: body.initiative_ids || [], type: body.type, deploy_at: body.deploy_at, release_at: body.release_at }); } catch (e) { return bad(e.message); }
|
|
68
|
+
return { status: 200, body: { ok: true, release: rel }, events: [{ event: 'created', ...rel }] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function editRelease(body, lines) {
|
|
72
|
+
const f = body.fields;
|
|
73
|
+
if (!f || typeof f !== 'object' || Array.isArray(f)) return bad('fields must be an object');
|
|
74
|
+
for (const k of Object.keys(f)) if (!R.EDITABLE.includes(k)) return bad(`${k} cannot be set here`);
|
|
75
|
+
if (!R.foldReleases(lines).find((r) => r.id === body.id)) return bad('no such release', 404);
|
|
76
|
+
if ('initiative_ids' in f && !isIdList(f.initiative_ids)) return bad('initiative_ids must be a list of initiative ids');
|
|
77
|
+
if ('type' in f && !R.TYPES.includes(f.type)) return bad(`type must be one of ${R.TYPES.join('|')}`);
|
|
78
|
+
for (const k of ['deploy_at', 'release_at']) if (k in f && f[k] !== null && Number.isNaN(Date.parse(f[k]))) return bad(`${k} must be a date or null`);
|
|
79
|
+
if ('fix_version' in f && f.fix_version !== null && typeof f.fix_version !== 'string') return bad('fix_version must be text or null');
|
|
80
|
+
if ('accepted' in f && typeof f.accepted !== 'boolean') return bad('accepted must be true or false');
|
|
81
|
+
return { status: 200, body: { ok: true }, events: [{ event: 'edited', id: body.id, fields: f }] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function deleteRelease(body, lines) {
|
|
85
|
+
const r = R.foldReleases(lines).find((x) => x.id === body.id);
|
|
86
|
+
if (!r) return bad('no such release', 404);
|
|
87
|
+
if (r.initiative_ids.length) return bad(`not deleted: ${r.initiative_ids.length} initiative(s) are assigned; remove them first`, 409);
|
|
88
|
+
return { status: 200, body: { ok: true }, events: [{ event: 'deleted', id: body.id }] };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function summarizeRelease(body, lines) {
|
|
92
|
+
if (!R.foldReleases(lines).find((r) => r.id === body.id)) return bad('no such release', 404);
|
|
93
|
+
if (!Array.isArray(body.lines)) return bad('lines must be a list of { text, source }');
|
|
94
|
+
const cited = R.citedSummary(body.lines);
|
|
95
|
+
if (!cited.ok) return { status: 422, body: { ok: false, error: 'every line needs a source: fact, estimate, assumption, reported or a ticket key', refused: cited.refused } };
|
|
96
|
+
const clean = body.lines.map((l) => ({ text: String(l.text == null ? '' : l.text), source: l.source }));
|
|
97
|
+
return { status: 200, body: { ok: true, lines: clean }, events: [{ event: 'summary', id: body.id, lines: clean }] };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ROUTES = {
|
|
101
|
+
'POST /api/releases/create': (b) => createRelease(b),
|
|
102
|
+
'POST /api/releases/edit': (b, c) => editRelease(b, c.lines),
|
|
103
|
+
'POST /api/releases/delete': (b, c) => deleteRelease(b, c.lines),
|
|
104
|
+
'POST /api/releases/summary': (b, c) => summarizeRelease(b, c.lines),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
module.exports = {
|
|
108
|
+
prefixes: ['/api/releases'],
|
|
109
|
+
view, createRelease, editRelease, deleteRelease, summarizeRelease, readBoard, cachedTickets, ROUTES,
|
|
110
|
+
handle: async (req, res, url, ctx) => {
|
|
111
|
+
const p = url.pathname;
|
|
112
|
+
if (p === '/api/releases' && req.method === 'GET') {
|
|
113
|
+
const jiraStatus = require('./jira-status.js');
|
|
114
|
+
ctx.json(res, view(readLines(file(ctx)), cachedTickets(ctx.root || ROOT, jiraStatus), readBoard(ctx)));
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
const key = `${req.method} ${p}`;
|
|
118
|
+
if (!ROUTES[key]) return false;
|
|
119
|
+
const got = await ctx.readJson(req);
|
|
120
|
+
if (!got.ok) { ctx.json(res, { ok: false, error: 'invalid JSON body' }, 400); return true; }
|
|
121
|
+
const body = got.body || {};
|
|
122
|
+
const g = guard.requirePagePress(req, body);
|
|
123
|
+
if (!g.ok) { ctx.json(res, { ok: false, error: g.error }, g.status); return true; }
|
|
124
|
+
const r = ROUTES[key](body, { lines: readLines(file(ctx)) });
|
|
125
|
+
if (r.status === 200 && r.events) for (const ev of r.events) R.appendRelease(file(ctx), ev);
|
|
126
|
+
ctx.json(res, r.body, r.status);
|
|
127
|
+
return true;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-report-facts.js — GET /api/report-families/facts[?ws=] (console3 wave D).
|
|
3
|
+
*
|
|
4
|
+
* { ok, facts: { project:[sections], product:[...], communications:[...], periodic:[...] }, sources }
|
|
5
|
+
*
|
|
6
|
+
* The report families view (report-families-view.js) reads its sections from ctx.reportFacts[<family id>]. This route builds
|
|
7
|
+
* them from the data that exists and from nothing else (scripts/report-families.js buildFamily copies what it is given):
|
|
8
|
+
* - project > progress one claim per open initiative that has a promised date (scripts/signals/facts.js readRepoFacts:
|
|
9
|
+
* the initiative record's `promised`), label fact, source the record's path;
|
|
10
|
+
* - periodic > highlights one claim per release (<runtime>/releases/releases.jsonl folded by scripts/releases.js),
|
|
11
|
+
* label fact, source that file.
|
|
12
|
+
* Every other section is empty, on purpose: no forecast, risk, decision, ask or metric record exists to read yet.
|
|
13
|
+
* Read-only. The repository read is slow (it derives each initiative's stage from git), so a successful read is kept 5 minutes. */
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const RF = require('../../scripts/report-families.js');
|
|
17
|
+
const { foldReleases } = require('../../scripts/releases.js');
|
|
18
|
+
|
|
19
|
+
const prefixes = ['/api/report-families/facts'];
|
|
20
|
+
const TTL = 5 * 60 * 1000;
|
|
21
|
+
const memo = new Map(); // root -> { at, inis }
|
|
22
|
+
|
|
23
|
+
function repoInis(root, opts) {
|
|
24
|
+
const hit = memo.get(root);
|
|
25
|
+
const now = (opts && opts.clock) ? opts.clock() : Date.now();
|
|
26
|
+
if (hit && now - hit.at < TTL) return hit.inis;
|
|
27
|
+
const { readRepoFacts } = require('../../scripts/signals/facts.js');
|
|
28
|
+
const inis = (readRepoFacts(root, {}).inis || []).map((i) => ({ id: i.id, title: i.title, promised: i.promised, done: !!i.done }));
|
|
29
|
+
memo.set(root, { at: now, inis });
|
|
30
|
+
return inis;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readReleases(runtimeDir) {
|
|
34
|
+
const f = path.join(runtimeDir, 'releases', 'releases.jsonl');
|
|
35
|
+
let lines = [];
|
|
36
|
+
try { lines = fs.readFileSync(f, 'utf8').split('\n').filter(Boolean); } catch { return { file: f, releases: [] }; }
|
|
37
|
+
return { file: f, releases: foldReleases(lines) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Pure: (initiatives, releases, { releasesFile }) -> { facts, sources }. A source with no rows adds no claim. */
|
|
41
|
+
function buildFacts(inis, releases, opts = {}) {
|
|
42
|
+
const progress = (inis || []).filter((i) => i && !i.done && i.promised)
|
|
43
|
+
.map((i) => ({ id: 'ini:' + i.id, text: `${i.title || i.id}: promised ${i.promised}`, label: 'fact', source: `planning/initiatives/${i.id}.md` }));
|
|
44
|
+
const highlights = (releases || []).map((r) => ({
|
|
45
|
+
id: 'rel:' + r.id, text: `Release ${r.fix_version || r.id} (${r.type})${r.release_at ? ', ' + String(r.release_at).slice(0, 10) : ''}`,
|
|
46
|
+
label: 'fact', source: opts.releasesFile || 'releases/releases.jsonl' }));
|
|
47
|
+
return {
|
|
48
|
+
facts: {
|
|
49
|
+
project: RF.buildFamily('project', { progress }),
|
|
50
|
+
product: RF.buildFamily('product', {}),
|
|
51
|
+
communications: RF.buildFamily('communications', {}),
|
|
52
|
+
periodic: RF.buildFamily('periodic', { highlights }),
|
|
53
|
+
},
|
|
54
|
+
sources: { initiatives_with_promise: progress.length, releases: highlights.length },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Pure handler over (opts: { root, runtimeDir, loadInis }) -> { code, body }. */
|
|
59
|
+
function factsOf(opts = {}) {
|
|
60
|
+
try {
|
|
61
|
+
const inis = (opts.loadInis || repoInis)(opts.root, opts);
|
|
62
|
+
const rel = opts.runtimeDir ? readReleases(opts.runtimeDir) : { file: null, releases: [] };
|
|
63
|
+
return { code: 200, body: { ok: true, ...buildFacts(inis, rel.releases, { releasesFile: rel.file ? path.relative(opts.root || '', rel.file) || rel.file : undefined }) } };
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return { code: 500, body: { ok: false, error: 'report facts could not be read: ' + (e && e.message ? e.message : String(e)) } };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = {
|
|
70
|
+
prefixes, buildFacts, factsOf,
|
|
71
|
+
handle: async (req, res, url, ctx) => {
|
|
72
|
+
if (url.pathname !== '/api/report-families/facts') return false;
|
|
73
|
+
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
74
|
+
let root;
|
|
75
|
+
try { const w = ctx.getWs && ctx.getWs(url.searchParams.get('ws') || null); root = w && w.root; } catch { root = null; }
|
|
76
|
+
const useRoot = root || path.resolve(__dirname, '..', '..');
|
|
77
|
+
let inis;
|
|
78
|
+
try { inis = ((await require('./facts-async.js').readFactsAsync(useRoot)).inis || []).map((i) => ({ id: i.id, title: i.title, promised: i.promised, done: !!i.done })); }
|
|
79
|
+
catch (e) { ctx.json(res, { ok: false, error: 'report facts could not be read: ' + (e && e.message ? e.message : String(e)) }, 500); return true; }
|
|
80
|
+
const out = factsOf({ root: useRoot, runtimeDir: ctx.runtimeDir, loadInis: () => inis });
|
|
81
|
+
ctx.json(res, out.body, out.code);
|
|
82
|
+
return true;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-report-families.js — console3 wave B4: the four report families and the claims check.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/report-families → { ok, families, labels, required }
|
|
5
|
+
* POST /api/report-families/check { family, variant?, sections, known_keys?, absent_keys?, stale_keys? }
|
|
6
|
+
* → { ok, blocked, reasons, warnings, sections, ... }
|
|
7
|
+
* Pure. The check reads nothing from disk and writes nothing. There is no send and no issue route in this wave. */
|
|
8
|
+
const RF = require('../../scripts/report-families.js');
|
|
9
|
+
|
|
10
|
+
const prefixes = ['/api/report-families'];
|
|
11
|
+
const strs = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : undefined);
|
|
12
|
+
|
|
13
|
+
module.exports = {
|
|
14
|
+
prefixes,
|
|
15
|
+
handle: async (req, res, url, ctx) => {
|
|
16
|
+
const p = url.pathname;
|
|
17
|
+
if (p === '/api/report-families' && req.method === 'GET') {
|
|
18
|
+
ctx.json(res, { ok: true, families: RF.FAMILIES, labels: RF.LABELS, required: RF.REQUIRED });
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (p === '/api/report-families/check' && req.method === 'POST') {
|
|
22
|
+
const got = await ctx.readJson(req);
|
|
23
|
+
if (!got.ok) { ctx.json(res, { ok: false, error: 'invalid JSON body' }, 400); return true; }
|
|
24
|
+
const b = got.body || {};
|
|
25
|
+
if (!Array.isArray(b.sections)) { ctx.json(res, { ok: false, error: 'sections must be a list' }, 400); return true; }
|
|
26
|
+
const out = RF.sendCheck({ family: b.family, variant: b.variant, sections: b.sections, knownKeys: strs(b.known_keys), absentKeys: strs(b.absent_keys), staleKeys: strs(b.stale_keys) });
|
|
27
|
+
ctx.json(res, out, out.ok ? 200 : 400);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
},
|
|
32
|
+
};
|