create-pmos 0.124.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/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-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,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* jobs-to-be-done.js — a standing need the PM serves, written as when, I want, so (planning-flows run 2).
|
|
3
|
+
*
|
|
4
|
+
* A job to be done is NOT a goal task (goals.js). A goal task is one thing to finish; a job is a need that many items
|
|
5
|
+
* serve over time. An item names a job by id (`job`), and the count is taken over every item that names it, whatever
|
|
6
|
+
* list the item is in.
|
|
7
|
+
*
|
|
8
|
+
* The append-only store has events `created`, `edited` and `deleted`. Like pm-items.js, the file is written only
|
|
9
|
+
* through appendEvent(), and the fold of the whole file is the current list. A deleted job's items are reassigned by
|
|
10
|
+
* item-edit events returned from deleteJob(), so the caller appends them in the same batch.
|
|
11
|
+
*/
|
|
12
|
+
const pmItems = require('./pm-items.js');
|
|
13
|
+
|
|
14
|
+
// Draft a job from one line. Only the three parts the PM wrote are filled; a missing part is left empty, never invented.
|
|
15
|
+
function draftJob(text) {
|
|
16
|
+
const s = String(text || '').replace(/\s+/g, ' ').trim();
|
|
17
|
+
const pick = (re) => { const m = s.match(re); return m ? m[1].trim().replace(/[,;]$/, '') : ''; };
|
|
18
|
+
return {
|
|
19
|
+
when: pick(/\bwhen\s+(.+?)(?=,?\s*I want\b|,?\s*so\b|$)/i),
|
|
20
|
+
want: pick(/\bI want\s+(.+?)(?=,?\s*so\b|$)/i),
|
|
21
|
+
so: pick(/\bso\s+(.+)$/i),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Fold the job events into current jobs, keyed by id.
|
|
26
|
+
function foldJobs(lines) {
|
|
27
|
+
const jobs = new Map();
|
|
28
|
+
for (const ev of lines) {
|
|
29
|
+
if (!ev || !ev.id) continue;
|
|
30
|
+
if (ev.event === 'created') jobs.set(ev.id, { ...ev.fields, id: ev.id });
|
|
31
|
+
else if (ev.event === 'edited' && jobs.has(ev.id)) jobs.set(ev.id, { ...jobs.get(ev.id), ...ev.fields });
|
|
32
|
+
else if (ev.event === 'deleted') jobs.delete(ev.id);
|
|
33
|
+
}
|
|
34
|
+
return [...jobs.values()];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Deleting a job: every item that names it is reassigned to `reassignTo` (another job's id, or null for none). The
|
|
38
|
+
// result is the job's `deleted` event plus one item edit per affected item, to be appended together.
|
|
39
|
+
function deleteJob(jobId, items, reassignTo, now = new Date()) {
|
|
40
|
+
const at = now.toISOString();
|
|
41
|
+
const affected = items.filter((i) => i.job === jobId);
|
|
42
|
+
const edits = affected.map((i) => ({ event: 'edited', id: i.id, fields: { job: reassignTo || null }, at }));
|
|
43
|
+
return { events: [{ event: 'deleted', id: jobId, at }, ...edits], reassigned: affected.length };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// `done of total` for one job, over every item that names it.
|
|
47
|
+
function progress(jobId, items) {
|
|
48
|
+
const mine = items.filter((i) => i.job === jobId);
|
|
49
|
+
return { done: mine.filter((i) => i.done === true).length, total: mine.length };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { draftJob, foldJobs, deleteJob, progress, appendEvent: pmItems.appendEvent, readLines: pmItems.readLines };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* outbox-store.js — the file layer over scripts/outbox.js (console3 wave B3). Sends NOTHING.
|
|
3
|
+
*
|
|
4
|
+
* <runtime>/outbox/outbox.jsonl append-only; one line per event: queued | edited | pressed | sent
|
|
5
|
+
* <runtime>/outbox/items/<id>.json the scripts/trust/outbox.js item, written only when a sender is registered
|
|
6
|
+
* <runtime>/outbox/sent.jsonl the trust ledger of sends (one 'send' line per send, with the override flag)
|
|
7
|
+
* <runtime>/outbox/promises.jsonl the promise ledger, through scripts/promises.js (scripts/trust/ledger.js)
|
|
8
|
+
*
|
|
9
|
+
* foldOutbox(lines) is pure: the latest version per id. A queued or edited line carries the state scripts/outbox.js
|
|
10
|
+
* computed from the trust claim rules: 'queued' or 'blocked'. A 'pressed' line (a PM press with no channel connected)
|
|
11
|
+
* leaves the state and records last_result. A 'pressed' or 'sent' line counts only for the hash it names, so an edit
|
|
12
|
+
* (a new hash) clears it. The send itself is scripts/trust/outbox.js (queue, confirm, execute): see executeSend.
|
|
13
|
+
*/
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const outbox = require('../../scripts/outbox.js');
|
|
17
|
+
const promises = require('../../scripts/promises.js');
|
|
18
|
+
const T = require('../../scripts/trust/outbox.js');
|
|
19
|
+
const L = require('../../scripts/trust/ledger.js');
|
|
20
|
+
|
|
21
|
+
const dirOf = (runtimeDir) => path.join(runtimeDir, 'outbox');
|
|
22
|
+
const outboxFile = (runtimeDir) => path.join(dirOf(runtimeDir), 'outbox.jsonl');
|
|
23
|
+
const itemsDir = (runtimeDir) => path.join(dirOf(runtimeDir), 'items');
|
|
24
|
+
const sentFile = (runtimeDir) => path.join(dirOf(runtimeDir), 'sent.jsonl');
|
|
25
|
+
const promisesFile = (runtimeDir) => path.join(dirOf(runtimeDir), 'promises.jsonl');
|
|
26
|
+
|
|
27
|
+
function readLines(file) {
|
|
28
|
+
let raw;
|
|
29
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const l of raw.split('\n')) {
|
|
32
|
+
if (!l.trim()) continue;
|
|
33
|
+
try { out.push(JSON.parse(l)); } catch { /* a torn line is skipped */ }
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function foldOutbox(lines = []) {
|
|
39
|
+
const byId = new Map();
|
|
40
|
+
for (const ev of lines) {
|
|
41
|
+
if (!ev || !ev.id) continue;
|
|
42
|
+
if (ev.event === 'queued' || ev.event === 'edited') {
|
|
43
|
+
const { event, ...entry } = ev;
|
|
44
|
+
byId.set(ev.id, { ...entry, state: ev.state === 'blocked' ? 'blocked' : 'queued', last_result: null, refused: null, created_at: (byId.get(ev.id) || {}).created_at || ev.at });
|
|
45
|
+
} else if (ev.event === 'pressed' || ev.event === 'sent') {
|
|
46
|
+
const cur = byId.get(ev.id);
|
|
47
|
+
if (!cur || cur.hash !== ev.hash) continue;
|
|
48
|
+
if (ev.event === 'sent') byId.set(ev.id, { ...cur, state: 'sent', last_result: 'sent' });
|
|
49
|
+
else byId.set(ev.id, { ...cur, last_result: ev.reason || null });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return [...byId.values()];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const readOutbox = (runtimeDir) => foldOutbox(readLines(outboxFile(runtimeDir)));
|
|
56
|
+
const appendEvent = (runtimeDir, event, now) => outbox.appendOutbox(outboxFile(runtimeDir), event, now);
|
|
57
|
+
|
|
58
|
+
const readPromises = (runtimeDir) => readLines(promisesFile(runtimeDir));
|
|
59
|
+
// `actor` comes from the call path (scripts/trust/ledger.js actorFrom); it is required, never read from the fields.
|
|
60
|
+
function addPromise(runtimeDir, fields, now, actor) {
|
|
61
|
+
const ev = promises.recordPromise(fields);
|
|
62
|
+
promises.appendPromise(promisesFile(runtimeDir), ev, actor, now);
|
|
63
|
+
return ev;
|
|
64
|
+
}
|
|
65
|
+
function markKept(runtimeDir, sourceEntryHash, now, actor) {
|
|
66
|
+
const ev = promises.keptEvent(sourceEntryHash);
|
|
67
|
+
promises.appendPromise(promisesFile(runtimeDir), ev, actor, now);
|
|
68
|
+
return ev;
|
|
69
|
+
}
|
|
70
|
+
const listBroken = (runtimeDir, today) => promises.brokenPromises(readPromises(runtimeDir), today);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The real send, only when a sender is registered. PM press (the page-press actor is the UI), then the server executes
|
|
74
|
+
* exactly what was confirmed, through scripts/trust/outbox.js. The connector runs inside execute and nowhere else.
|
|
75
|
+
*/
|
|
76
|
+
function executeSend(runtimeDir, entry, { reason, claimsSend }, sender, now) {
|
|
77
|
+
const dir = itemsDir(runtimeDir);
|
|
78
|
+
const ui = L.actorFrom({ ui: true });
|
|
79
|
+
const item = { id: entry.id, channel: entry.channel, to: entry.recipient, subject: entry.subject || '', body: entry.body };
|
|
80
|
+
T.queue(dir, item, ui, { send: claimsSend });
|
|
81
|
+
T.confirm(dir, entry.id, ui, { reason });
|
|
82
|
+
const r = T.execute(dir, entry.id, T.SERVER, (o) => sender(o), sentFile(runtimeDir));
|
|
83
|
+
appendEvent(runtimeDir, { event: 'sent', id: entry.id, hash: entry.hash }, now);
|
|
84
|
+
return r;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { executeSend, itemsDir, sentFile, outboxFile, promisesFile, readLines, foldOutbox, readOutbox, appendEvent, readPromises, addPromise, markKept, listBroken };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* pm-items.js — the PM's own work items (planning-flows run 1, PRD decision 2).
|
|
3
|
+
*
|
|
4
|
+
* An append-only JSONL store of items the PM adds for themselves (created here) or that came from a source
|
|
5
|
+
* (imported: Jira, Gmail, Figma, local). Nothing is edited in place: an edit, a drop and a delete are new lines,
|
|
6
|
+
* and the fold of the whole file is the current list. Stage is never stored; a list is derived from the due
|
|
7
|
+
* date, priority and waiting-on fields by placement() below.
|
|
8
|
+
*
|
|
9
|
+
* SINGLE WRITER: appendEvent() is the only function that writes the file. Callers pass the event; they never
|
|
10
|
+
* choose `at` or `by` themselves (attribution comes from the call path, as in plan-items.js).
|
|
11
|
+
*/
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
const UNDO_MS = 8000;
|
|
16
|
+
const LISTS = ['waiting', 'today', 'week', 'triage', 'later'];
|
|
17
|
+
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
18
|
+
|
|
19
|
+
const DUE_WORD = /\b(?:due|by)\s+(today|tomorrow|sun(?:day)?|mon(?:day)?|tue(?:s(?:day)?)?|wed(?:nesday)?|thu(?:r(?:s(?:day)?)?)?|fri(?:day)?|sat(?:urday)?)\b/i;
|
|
20
|
+
function iso(d) { return d.toISOString().slice(0, 10); }
|
|
21
|
+
function addDays(d, n) { const x = new Date(d.getTime()); x.setUTCDate(x.getUTCDate() + n); return x; }
|
|
22
|
+
|
|
23
|
+
// Parse one line the PM typed or dictated. `today` is a Date (UTC midnight is fine). Returns the fields only; the
|
|
24
|
+
// caller decides what to do with an empty title.
|
|
25
|
+
function parseLine(text, today) {
|
|
26
|
+
let rest = String(text || '').trim();
|
|
27
|
+
const out = { title: '', due: null, priority: null, waiting_on: null };
|
|
28
|
+
// Priority: "P1" (as before) or the shorthand "!1", the mockup's "Try: Send Dana the new date by Fri !1".
|
|
29
|
+
const p = rest.match(/\bP([1-3])\b/i) || rest.match(/(?:^|\s)!([1-3])(?=\s|$)/);
|
|
30
|
+
if (p) { out.priority = `P${p[1]}`; rest = rest.replace(p[0], ' '); }
|
|
31
|
+
const w = rest.match(/\bwaiting on\s+([^,;]+?)(?=\s+(?:by|due|P[1-3])\b|$)/i);
|
|
32
|
+
if (w) { out.waiting_on = w[1].trim(); rest = rest.replace(w[0], ' '); }
|
|
33
|
+
const dueIso = rest.match(/\b(?:due|by)\s+(\d{4}-\d{2}-\d{2})\b/i);
|
|
34
|
+
if (dueIso) { out.due = dueIso[1]; rest = rest.replace(dueIso[0], ' '); }
|
|
35
|
+
else {
|
|
36
|
+
const word = rest.match(DUE_WORD);
|
|
37
|
+
if (word) {
|
|
38
|
+
const k = word[1].toLowerCase();
|
|
39
|
+
let d = new Date(today.getTime());
|
|
40
|
+
if (k === 'tomorrow') d = addDays(d, 1);
|
|
41
|
+
else if (k !== 'today') {
|
|
42
|
+
// The next occurrence, counting today as a match only when it is that weekday. A day may be abbreviated: Fri, Tue, Thu.
|
|
43
|
+
const diff = (WEEKDAYS.findIndex((w) => w.startsWith(k.slice(0, 3))) - d.getUTCDay() + 7) % 7;
|
|
44
|
+
d = addDays(d, diff);
|
|
45
|
+
}
|
|
46
|
+
out.due = iso(d);
|
|
47
|
+
rest = rest.replace(word[0], ' ');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
out.title = rest.replace(/\s+/g, ' ').replace(/[,;]\s*$/, '').trim();
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Place an item in one of five lists. Waiting first (the PM is not the one who must act), then the date, then
|
|
55
|
+
// undated items in Triage, then everything dated later.
|
|
56
|
+
function placement(item, today) {
|
|
57
|
+
// An explicit move wins, then a park; both hold until the PM moves or unparks the item.
|
|
58
|
+
if (item.list_override && LISTS.includes(item.list_override)) return item.list_override;
|
|
59
|
+
if (item.parked) return 'later';
|
|
60
|
+
if (item.waiting_on) return 'waiting';
|
|
61
|
+
if (!item.due) return 'triage';
|
|
62
|
+
const due = item.due;
|
|
63
|
+
const t = iso(today);
|
|
64
|
+
if (due <= t) return 'today';
|
|
65
|
+
if (due <= iso(addDays(today, 7))) return 'week';
|
|
66
|
+
return 'later';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Fold the event lines into the current items, keyed by id. A dropped or deleted item is removed.
|
|
70
|
+
function fold(lines) {
|
|
71
|
+
const items = new Map();
|
|
72
|
+
for (const ev of lines) {
|
|
73
|
+
if (!ev || !ev.id) continue;
|
|
74
|
+
if (ev.event === 'created') items.set(ev.id, { ...ev.fields, id: ev.id, source: ev.source || 'local', created_at: ev.at });
|
|
75
|
+
else if (ev.event === 'edited' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), ...ev.fields });
|
|
76
|
+
else if (ev.event === 'done' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), done: true, done_at: ev.at });
|
|
77
|
+
else if (ev.event === 'reopened' && items.has(ev.id)) items.set(ev.id, { ...items.get(ev.id), done: false, done_at: null });
|
|
78
|
+
else if (ev.event === 'dropped' || ev.event === 'deleted') items.delete(ev.id);
|
|
79
|
+
}
|
|
80
|
+
return [...items.values()];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The delete rule: an item created here is deleted; an imported one is only dropped from the PM's list (its source
|
|
84
|
+
// keeps it). The caller passes the item's source so the rule is applied in one place.
|
|
85
|
+
// The removal keeps a snapshot of the item, so an undo inside the window can restore it exactly.
|
|
86
|
+
function removalEvent(item, now) {
|
|
87
|
+
const created = !item.source || item.source === 'local';
|
|
88
|
+
return { event: created ? 'deleted' : 'dropped', id: item.id, at: now.toISOString(), snapshot: { ...item } };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The created event that restores the last removal of `id`, when it is still inside the undo window. Null otherwise.
|
|
92
|
+
function undoEvent(lines, id, now = new Date()) {
|
|
93
|
+
const removal = [...lines].reverse().find((ev) => ev && ev.id === id && (ev.event === 'deleted' || ev.event === 'dropped'));
|
|
94
|
+
if (!removal || !removal.snapshot || !canUndo(removal.at, now)) return null;
|
|
95
|
+
const { id: _id, source, created_at, ...fields } = removal.snapshot;
|
|
96
|
+
return { event: 'created', id, fields, source: source || 'local', restored: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function canUndo(removedAt, now) {
|
|
100
|
+
return now.getTime() - new Date(removedAt).getTime() <= UNDO_MS;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The one writer. The caller passes the file path and the event; `at` is set here.
|
|
104
|
+
function appendEvent(file, event, now = new Date()) {
|
|
105
|
+
const line = JSON.stringify({ ...event, at: now.toISOString() }) + '\n';
|
|
106
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
107
|
+
fs.appendFileSync(file, line);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readLines(file) {
|
|
111
|
+
if (!fs.existsSync(file)) return [];
|
|
112
|
+
return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { UNDO_MS, LISTS, parseLine, placement, fold, removalEvent, undoEvent, canUndo, appendEvent, readLines };
|
|
@@ -38,6 +38,27 @@ const MODULES = Object.freeze([
|
|
|
38
38
|
'routes-reports', // Run 5 (5C): drafts, Issue, notes
|
|
39
39
|
'routes-notes', // meeting-notes Run 1: the Notes view (typed notes, dictation, transcripts, summaries)
|
|
40
40
|
'routes-my-work', // initiative my-work: the ranked list of work assigned to the PM, the brief, Read again
|
|
41
|
+
'routes-intake', // planning-flows run 4: one-line intake draft (drafts only)
|
|
42
|
+
'routes-pm-items', // planning-flows runs 1-2: the PM's own items and jobs to be done (add, edit, remove, undo)
|
|
43
|
+
'routes-runs', // console3 agent surfaces: runs grouped for the sessions panel, PM answers, corrections
|
|
44
|
+
'routes-releases', // console3 A4: releases ledger, read side
|
|
45
|
+
'routes-doc-health', // console3 B2: the six document health checks, recomputed from text
|
|
46
|
+
'routes-outbox', // console3 B3: the outbox queue, hash-bound confirm (no channel connected)
|
|
47
|
+
'routes-report-families', // console3 B4: the four report families and the claims check (read-only)
|
|
48
|
+
'routes-forecast', // console3 B5: forecast, critical path, time machine and capacity (read-only)
|
|
49
|
+
'routes-calendar-views', // console3 C2: Day/Week/Month/Quarter/Year cells, Gantt and the strategy lens (read-only)
|
|
50
|
+
'routes-briefing', // console3 C1: the day-part Briefing cards from the signal engine (read-only)
|
|
51
|
+
'routes-report-facts', // console3 D: GET /api/report-families/facts (read-only, real data only)
|
|
52
|
+
'routes-records', // console3 E1a: ideas, hypotheses, decisions, KR metrics, bets (GET list; page-press add/edit/reading)
|
|
53
|
+
'routes-doc-sheet', // console3 E1c: document sheet: revisions, edit with why, propose and accept by hash
|
|
54
|
+
'routes-calendar-import', // console3 E2a: cached calendar events (page-press import; metadata only, sensitivity required)
|
|
55
|
+
'routes-roadmap-ops', // console3 E1b: bets (add, move with swap proposal, take off, anchor suggestions), breakdown, people
|
|
56
|
+
'routes-signals', // console3 E2a: signal rules, readings and trips
|
|
57
|
+
'routes-c3-init', // console3 Initiatives sub-views of /c3.html: Roadmap and Breakdown reads (read-only)
|
|
58
|
+
'routes-c3-timeline', // console3 Initiatives Timeline of /c3.html: every check of the plan as a whole frame, three sources in one Gantt (read-only)
|
|
59
|
+
'routes-c3-life', // console3 Lifecycle lens panels: newest evaluation per initiative, corrections per run, evidence sources (read-only)
|
|
60
|
+
'routes-c3-strategy', // console3 Calendar Quarter and Year panes: outcomes, north star, signals, synergy, bets (read-only)
|
|
61
|
+
'routes-hooks', // planning-flows run 7: CLI hook endpoints (session token, per-run JSONL)
|
|
41
62
|
]);
|
|
42
63
|
const PM_DIR = path.join(__dirname, '..', 'public', 'pm');
|
|
43
64
|
const MODULE_NAME = /^[a-z0-9][a-z0-9-]*\.js$/;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-briefing.js — GET /api/briefing?lens=Plan|Unblock|Close|Reflect&zoom=day|week|month[&now=ISO] (console3 wave C1).
|
|
3
|
+
*
|
|
4
|
+
* { ok, lens, zoom, cards, ticker, banner }
|
|
5
|
+
*
|
|
6
|
+
* `cards` are the engine's selected cards (scripts/signals present() over readRepoFacts of the repo root), with the card
|
|
7
|
+
* anatomy untouched. `ticker` is one line per top card, built from the card's own headline and deadline. `banner` is the
|
|
8
|
+
* pinned Critical card, or null. Read-only: no write route. The default lens follows the clock. An engine failure answers
|
|
9
|
+
* { ok:false, error } with 500 and never throws out of the handler.
|
|
10
|
+
*/
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const LENSES = ['Plan', 'Unblock', 'Close', 'Reflect'];
|
|
14
|
+
const ZOOMS = ['day', 'week', 'month'];
|
|
15
|
+
|
|
16
|
+
function lensFor(date) {
|
|
17
|
+
const h = date.getHours();
|
|
18
|
+
return h < 11 ? 'Plan' : h < 14 ? 'Unblock' : h < 17 ? 'Close' : 'Reflect';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function tickerOf(cards) {
|
|
22
|
+
return cards.slice(0, 3).map((c) => {
|
|
23
|
+
const d = c.deadline && c.deadline.days != null ? ' (' + (c.deadline.days < 1 ? 'within a day' : Math.round(c.deadline.days) + 'd') + ')' : '';
|
|
24
|
+
return c.tag + ': ' + c.headline + d;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// readRepoFacts derives every initiative's stage from git (minutes for ~300 records), so one read per root and day is kept 5 minutes.
|
|
29
|
+
const factsMemo = new Map();
|
|
30
|
+
function factsOnce(eng, root, iso) {
|
|
31
|
+
const k = root + '|' + iso, hit = factsMemo.get(k), now = Date.now();
|
|
32
|
+
if (hit && now - hit.at < 5 * 60 * 1000) return hit.facts;
|
|
33
|
+
const facts = eng.readRepoFacts(root, { today: iso });
|
|
34
|
+
factsMemo.clear(); factsMemo.set(k, { at: now, facts });
|
|
35
|
+
return facts;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadEngine() {
|
|
39
|
+
// the engine is this repo's; `root` only says which repo's initiatives it reads
|
|
40
|
+
const sig = path.resolve(__dirname, '..', '..', 'scripts', 'signals');
|
|
41
|
+
const { present } = require(path.join(sig, 'index.js'));
|
|
42
|
+
const { readRepoFacts } = require(path.join(sig, 'facts.js'));
|
|
43
|
+
return { present, readRepoFacts };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Pure handler: (query, { root, now, load }) -> { code, body }. `load` is injectable for tests. */
|
|
47
|
+
function briefing(query, opts = {}) {
|
|
48
|
+
const q = query || {};
|
|
49
|
+
const root = opts.root || path.resolve(__dirname, '..', '..');
|
|
50
|
+
let now = q.now ? new Date(q.now) : (opts.now ? new Date(opts.now) : new Date());
|
|
51
|
+
if (isNaN(now.getTime())) now = new Date();
|
|
52
|
+
const lens = LENSES.includes(q.lens) ? q.lens : lensFor(now);
|
|
53
|
+
const zoom = ZOOMS.includes(q.zoom) ? q.zoom : 'week';
|
|
54
|
+
try {
|
|
55
|
+
const eng = (opts.load || loadEngine)(root);
|
|
56
|
+
const iso = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0');
|
|
57
|
+
const facts = opts.load ? eng.readRepoFacts(root, { today: iso }) : factsOnce(eng, root, iso);
|
|
58
|
+
let useLens = lens;
|
|
59
|
+
let cards = eng.present(facts, { lens, zoom, today: iso }).cards || [];
|
|
60
|
+
// When the clock's lens has no card, show the lens that has the most (ties keep the clock's lens). On the real repo
|
|
61
|
+
// this is usually all four empty: the briefing detectors read promised dates, forecasts, holders and the like, and no
|
|
62
|
+
// initiative record carries them yet. The answer then stays empty and says so by fallback_lens being absent.
|
|
63
|
+
let fallback = false;
|
|
64
|
+
if (!cards.length && !q.lens) {
|
|
65
|
+
for (const l of LENSES) {
|
|
66
|
+
if (l === lens) continue;
|
|
67
|
+
const c = eng.present(facts, { lens: l, zoom, today: iso }).cards || [];
|
|
68
|
+
if (c.length > cards.length) { cards = c; useLens = l; fallback = true; }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const banner = cards.find((c) => c.pinned && c.tag === 'Critical') || null;
|
|
72
|
+
return { code: 200, body: { ok: true, lens: useLens, zoom, cards, ticker: tickerOf(cards), banner, ...(fallback ? { fallback_lens: true } : {}) } };
|
|
73
|
+
} catch (e) {
|
|
74
|
+
return { code: 500, body: { ok: false, error: 'the signal engine failed: ' + (e && e.message ? e.message : String(e)) } };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
prefixes: ['/api/briefing'],
|
|
80
|
+
briefing, lensFor, tickerOf,
|
|
81
|
+
handle: async (req, res, url, ctx) => {
|
|
82
|
+
if (url.pathname !== '/api/briefing') return false;
|
|
83
|
+
if (req.method !== 'GET') { ctx.json(res, { ok: false, error: 'GET only' }, 405); return true; }
|
|
84
|
+
const q = Object.fromEntries(url.searchParams.entries());
|
|
85
|
+
let root;
|
|
86
|
+
try { const w = ctx.getWs && ctx.getWs(q.ws || null); root = w && w.root; } catch { root = null; }
|
|
87
|
+
// The repo read runs in a child process (facts-async.js), so the server keeps answering while it works.
|
|
88
|
+
const useRoot = root || path.resolve(__dirname, '..', '..');
|
|
89
|
+
let now = q.now ? new Date(q.now) : new Date();
|
|
90
|
+
if (isNaN(now.getTime())) now = new Date();
|
|
91
|
+
const iso = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0');
|
|
92
|
+
let facts;
|
|
93
|
+
try { facts = await require('./facts-async.js').readFactsAsync(useRoot, { today: iso }); }
|
|
94
|
+
catch (e) { ctx.json(res, { ok: false, error: 'the signal engine failed: ' + (e && e.message ? e.message : String(e)) }, 500); return true; }
|
|
95
|
+
const eng = loadEngine();
|
|
96
|
+
const out = briefing(q, { root: useRoot, load: () => ({ present: eng.present, readRepoFacts: () => facts }) });
|
|
97
|
+
ctx.json(res, out.body, out.code);
|
|
98
|
+
return true;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-c3-init.js — read-only data for the Initiatives sub-views of /c3.html (Roadmap and Breakdown). Nothing is written here.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/c3-init/roadmap → { ok, bets, capacity, people, krs } bets: the folded bets records; capacity: roadmap-ops capacityLine
|
|
5
|
+
* GET /api/c3-init/breakdown → { ok, items, phases, sources, people, capacity, history }
|
|
6
|
+
* items: scripts/breakdown-ops list (PMOS-only work); capacity: capacityLoad per person over 2 weeks;
|
|
7
|
+
* history: the last 8 add/edit/delete/dependency events, newest first; events: all of them, oldest first (who added and changed each item).
|
|
8
|
+
* People come from <runtime>/people.json, the file routes-forecast.js and POST /api/roadmap-ops/people use. The writes are the
|
|
9
|
+
* existing POST /api/roadmap-ops/* routes, which this file does not touch. */
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const roadmapOps = require('./routes-roadmap-ops.js');
|
|
13
|
+
const records = require(path.join(__dirname, '..', '..', 'scripts', 'records', 'index.js'));
|
|
14
|
+
const roadmap = require(path.join(__dirname, '..', '..', 'scripts', 'roadmap-ops.js'));
|
|
15
|
+
const breakdown = require(path.join(__dirname, '..', '..', 'scripts', 'breakdown-ops.js'));
|
|
16
|
+
|
|
17
|
+
function readPeople(runtimeDir) {
|
|
18
|
+
try {
|
|
19
|
+
const j = JSON.parse(fs.readFileSync(path.join(runtimeDir, 'people.json'), 'utf8'));
|
|
20
|
+
return (j.people || []).filter((p) => p && p.id).map((p) => ({ id: p.id, points_per_week: Number(p.points_per_week) || 5 }));
|
|
21
|
+
} catch { return []; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function roadmapRead(ctx) {
|
|
25
|
+
const root = roadmapOps.rootOf(ctx);
|
|
26
|
+
const bets = records.list(root, 'bets');
|
|
27
|
+
const people = readPeople(ctx.runtimeDir);
|
|
28
|
+
const krs = records.list(root, 'kr-metrics').map((k) => ({ id: k.rid, title: k.metric }));
|
|
29
|
+
return { status: 200, body: { ok: true, bets, capacity: roadmap.capacityLine(bets, people), people, krs } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function breakdownRead(ctx) {
|
|
33
|
+
const root = roadmapOps.rootOf(ctx);
|
|
34
|
+
const items = breakdown.list(root);
|
|
35
|
+
const people = readPeople(ctx.runtimeDir);
|
|
36
|
+
const events = breakdown.readLines(root).filter((e) => ['add', 'edit', 'delete', 'dependency'].includes(e.kind))
|
|
37
|
+
.map((e) => ({ kind: e.kind, rid: e.rid, why: e.why || '', at: e.at, by: e.by || '', title: (e.fields && e.fields.title) || (e.snapshot && e.snapshot.title) || '' }));
|
|
38
|
+
const history = events.slice(-8).reverse();
|
|
39
|
+
return { status: 200, body: { ok: true, items, phases: breakdown.PHASES, sources: breakdown.SOURCES, people, capacity: breakdown.capacityLoad(items, people, 2), history, events } };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = {
|
|
43
|
+
prefixes: ['/api/c3-init'],
|
|
44
|
+
roadmapRead, breakdownRead,
|
|
45
|
+
handle: async (req, res, url, ctx) => {
|
|
46
|
+
if (req.method !== 'GET') return false;
|
|
47
|
+
const sub = url.pathname.slice('/api/c3-init'.length);
|
|
48
|
+
const r = sub === '/roadmap' ? roadmapRead(ctx) : sub === '/breakdown' ? breakdownRead(ctx) : null;
|
|
49
|
+
if (!r) return false;
|
|
50
|
+
ctx.json(res, r.body, r.status);
|
|
51
|
+
return true;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* routes-c3-life.js — read-only data for the Lifecycle lens of /c3.html (the panels the lens draws beyond the stage list). Nothing is written here.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/c3-life → { ok, evals, corrections, sources, as_of }
|
|
5
|
+
* evals: the newest evaluation row per initiative from planning/runs/evals.jsonl:
|
|
6
|
+
* { [initiative_id]: { run_id, score, threshold, verdict, dimensions, evaluator_model, recorded_at, findings: [{ severity, text }] (3 at most) } }
|
|
7
|
+
* corrections: human corrections per agent run from planning/runs/agent-runs.jsonl: { runs, corrections, series: [{ from, runs, corrections }] }
|
|
8
|
+
* (the last six 7-day buckets that hold a run, oldest first; a run counts in the week of its created_at)
|
|
9
|
+
* capacity: { points, basis } effort points for two weeks from the people file (points_per_week of each person, times two), or null when no person is recorded
|
|
10
|
+
* sources: for every `src` named by the evidence of a hypotheses record: true when that file exists inside the repository. A path that
|
|
11
|
+
* leaves the repository (`..`, an absolute path outside it) is never read and reads false.
|
|
12
|
+
* Gates need no route: GET /api/console?name=ladder&id=<initiative> already returns the ladder steps. Calibration: GET /api/console?name=radar.
|
|
13
|
+
* Repo facts are not read here (readRepoFacts is synchronous and slow); only two small JSONL files and the existence of named files are. */
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const records = require(path.join(__dirname, '..', '..', 'scripts', 'records', 'index.js'));
|
|
17
|
+
|
|
18
|
+
const REPO = path.join(__dirname, '..', '..');
|
|
19
|
+
const DAY = 86400000;
|
|
20
|
+
const memo = new Map(); // file -> { key, lines }
|
|
21
|
+
|
|
22
|
+
/** The lines of a JSONL file, parsed; a line that does not parse is skipped. Cached by size and modified time. */
|
|
23
|
+
function readJsonl(file) {
|
|
24
|
+
let st;
|
|
25
|
+
try { st = fs.statSync(file); } catch { return []; }
|
|
26
|
+
const key = st.size + ':' + st.mtimeMs;
|
|
27
|
+
const hit = memo.get(file);
|
|
28
|
+
if (hit && hit.key === key) return hit.lines;
|
|
29
|
+
const lines = [];
|
|
30
|
+
try { for (const l of fs.readFileSync(file, 'utf8').split('\n')) { if (!l.trim()) continue; try { lines.push(JSON.parse(l)); } catch { /* a bad line is skipped */ } } } catch { return []; }
|
|
31
|
+
memo.set(file, { key, lines });
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const num = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
36
|
+
|
|
37
|
+
/** The newest evaluation per initiative (recorded_at, then file order). Findings are cut to three short lines. */
|
|
38
|
+
function latestEvals(rows) {
|
|
39
|
+
const best = {};
|
|
40
|
+
rows.forEach((r, i) => {
|
|
41
|
+
const id = r && r.initiative_id; if (!id) return;
|
|
42
|
+
const at = String(r.recorded_at || r.at || '');
|
|
43
|
+
const p = best[id];
|
|
44
|
+
if (!p || at >= p.at) best[id] = { at, i, r };
|
|
45
|
+
});
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const [id, { r }] of Object.entries(best)) {
|
|
48
|
+
const dims = {};
|
|
49
|
+
if (r.dimensions && typeof r.dimensions === 'object') for (const [k, v] of Object.entries(r.dimensions)) if (num(v)) dims[k] = v;
|
|
50
|
+
out[id] = {
|
|
51
|
+
run_id: r.run_id == null ? '' : String(r.run_id), score: num(r.score) ? r.score : null,
|
|
52
|
+
threshold: num(r.threshold) ? r.threshold : num(r.pass_threshold) ? r.pass_threshold : null,
|
|
53
|
+
verdict: String(r.verdict || ''), dimensions: dims, ...(Array.isArray(r.dims) ? { dims: r.dims.filter((d) => d && num(d.score)).map((d) => ({ dim: String(d.dim || ''), w: num(d.w) ? d.w : null, score: d.score })) } : {}), evaluator_model: String(r.evaluator_model || ''), recorded_at: String(r.recorded_at || r.at || ''),
|
|
54
|
+
findings: (Array.isArray(r.findings) ? r.findings : []).filter((f) => f && typeof f === 'object' && f.text).slice(0, 3).map((f) => ({ severity: String(f.severity || ''), text: String(f.text).slice(0, 240) })),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Human corrections per run, in the last six 7-day buckets that hold a run. A run with no human_corrections number counts as a run with none recorded: left out. */
|
|
61
|
+
function correctionSeries(rows) {
|
|
62
|
+
const runs = rows.filter((r) => r && num(r.human_corrections) && Date.parse(r.created_at || r.recorded_at || ''));
|
|
63
|
+
if (!runs.length) return { runs: 0, corrections: 0, series: [] };
|
|
64
|
+
const t = (r) => Date.parse(r.created_at || r.recorded_at);
|
|
65
|
+
const first = Math.min(...runs.map(t));
|
|
66
|
+
const buckets = new Map();
|
|
67
|
+
for (const r of runs) { const k = Math.floor((t(r) - first) / (7 * DAY)); const b = buckets.get(k) || { from: new Date(first + k * 7 * DAY).toISOString().slice(0, 10), runs: 0, corrections: 0 }; b.runs += 1; b.corrections += r.human_corrections; buckets.set(k, b); }
|
|
68
|
+
const series = [...buckets.entries()].sort((a, b) => a[0] - b[0]).map(([, b]) => b).slice(-6);
|
|
69
|
+
return { runs: runs.length, corrections: runs.reduce((a, r) => a + r.human_corrections, 0), series };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** For every evidence `src` of a hypotheses record: does that file exist inside `root`? Nothing outside `root` is looked at. */
|
|
73
|
+
function sourcesOf(root, hyps) {
|
|
74
|
+
const out = {};
|
|
75
|
+
const base = path.resolve(root);
|
|
76
|
+
for (const h of hyps) for (const e of (Array.isArray(h.evidence) ? h.evidence : [])) {
|
|
77
|
+
const s = e && typeof e.src === 'string' ? e.src : '';
|
|
78
|
+
if (!s || s in out) continue;
|
|
79
|
+
const abs = path.resolve(base, s);
|
|
80
|
+
out[s] = (abs === base || abs.startsWith(base + path.sep)) && fs.existsSync(abs);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Effort points the team holds over two weeks: the people file (<runtime>/people.json, the one routes-c3-init.js reads), points_per_week summed, times two. null when none. */
|
|
86
|
+
function capacityOf(runtimeDir) {
|
|
87
|
+
let people = [];
|
|
88
|
+
try { people = (JSON.parse(fs.readFileSync(path.join(runtimeDir, 'people.json'), 'utf8')).people || []).filter((p) => p && p.id && num(Number(p.points_per_week))); } catch { people = []; }
|
|
89
|
+
if (!people.length) return null;
|
|
90
|
+
const ppw = people.reduce((a, p) => a + Number(p.points_per_week), 0);
|
|
91
|
+
return { points: ppw * 2, basis: 'Team of ' + people.length + ' at ' + ppw + ' points a week, over 2 weeks' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function read(ctx) {
|
|
95
|
+
const root = (ctx && ctx.repoRoot) || REPO;
|
|
96
|
+
const recordsDir = (ctx && ctx.recordsDir) || path.join(root, 'planning', 'records');
|
|
97
|
+
let hyps = [];
|
|
98
|
+
try { hyps = records.list(recordsDir, 'hypotheses'); } catch { hyps = []; }
|
|
99
|
+
return { status: 200, body: {
|
|
100
|
+
ok: true,
|
|
101
|
+
evals: latestEvals(readJsonl(path.join(root, 'planning', 'runs', 'evals.jsonl'))),
|
|
102
|
+
corrections: correctionSeries(readJsonl(path.join(root, 'planning', 'runs', 'agent-runs.jsonl'))),
|
|
103
|
+
capacity: ctx && ctx.runtimeDir ? capacityOf(ctx.runtimeDir) : null,
|
|
104
|
+
sources: sourcesOf(root, hyps),
|
|
105
|
+
as_of: new Date().toISOString(),
|
|
106
|
+
} };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
prefixes: ['/api/c3-life'],
|
|
111
|
+
read, latestEvals, correctionSeries, sourcesOf, capacityOf,
|
|
112
|
+
handle: async (req, res, url, ctx) => {
|
|
113
|
+
if (req.method !== 'GET' || url.pathname !== '/api/c3-life') return false;
|
|
114
|
+
let r;
|
|
115
|
+
try { r = read(ctx); } catch (e) { r = { status: 500, body: { ok: false, error: 'the lifecycle panels could not be read: ' + String((e && e.message) || 'error').slice(0, 120) } }; }
|
|
116
|
+
ctx.json(res, r.body, r.status);
|
|
117
|
+
return true;
|
|
118
|
+
},
|
|
119
|
+
};
|