create-pmos 0.133.0 → 0.135.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/bin/notes-replay.js +235 -0
- package/workspace/c3-briefing-test.js +130 -0
- package/workspace/c3-foundation-test.js +159 -0
- package/workspace/c3-ib-items-test.js +105 -0
- package/workspace/c3-ib-more-test.js +120 -0
- package/workspace/c3-ib-triage-backlog-test.js +119 -0
- package/workspace/c3-ini-harness.js +66 -0
- package/workspace/c3-initiatives-test.js +140 -0
- package/workspace/c3-one-home-test.js +14 -1
- package/workspace/c3-reports-test.js +57 -18
- package/workspace/c3-settings-test.js +139 -0
- package/workspace/c3-work-test.js +42 -10
- package/workspace/fixtures/one-home-baseline.json +1 -9
- package/workspace/guide/briefing-cards.md +15 -3
- package/workspace/guide/briefing.md +12 -8
- package/workspace/guide/calendar-popup.md +2 -2
- package/workspace/guide/console-calendar.md +39 -10
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/initiative-boards.md +65 -0
- package/workspace/guide/jira.md +3 -1
- package/workspace/guide/layout.md +2 -0
- package/workspace/guide/my-work.md +5 -3
- package/workspace/guide/notes.md +35 -2
- package/workspace/guide/reports-dashboards.md +29 -5
- package/workspace/guide/settings.md +68 -0
- package/workspace/guide/welcome.md +10 -10
- package/workspace/lib/calendar-cli.js +3 -2
- package/workspace/lib/calendar-layers.js +21 -10
- package/workspace/lib/calendar-tidy.js +230 -0
- package/workspace/lib/engine/boards.js +542 -0
- package/workspace/lib/engine/breakdown-ops.js +180 -0
- package/workspace/lib/engine/calendar-import.js +84 -0
- package/workspace/lib/engine/calendar-model.js +185 -0
- package/workspace/lib/engine/card-state.js +92 -0
- package/workspace/lib/engine/derived/derive.js +92 -0
- package/workspace/lib/engine/derived/plan.js +3 -0
- package/workspace/lib/engine/derived/trust.js +3 -0
- package/workspace/lib/engine/doc-health.js +142 -0
- package/workspace/lib/engine/doc-sheet.js +75 -0
- package/workspace/lib/engine/intake.js +78 -0
- package/workspace/lib/engine/outbox.js +86 -0
- package/workspace/lib/engine/plan-forecast.js +342 -0
- package/workspace/lib/engine/promises.js +29 -0
- package/workspace/lib/engine/records/index.js +114 -0
- package/workspace/lib/engine/releases.js +96 -0
- package/workspace/lib/engine/replies.js +99 -0
- package/workspace/lib/engine/reply-contract/reply-contract.js +372 -0
- package/workspace/lib/engine/report-families.js +175 -0
- package/workspace/lib/engine/roadmap-ops.js +150 -0
- package/workspace/lib/engine/sessions-list.js +27 -0
- package/workspace/lib/engine/signal-rules.js +95 -0
- package/workspace/lib/engine/signals/cards.js +57 -0
- package/workspace/lib/engine/signals/detectors.js +370 -0
- package/workspace/lib/engine/signals/facts-cli.js +8 -0
- package/workspace/lib/engine/signals/facts.js +197 -0
- package/workspace/lib/engine/signals/index.js +33 -0
- package/workspace/lib/engine/signals/kinds.js +110 -0
- package/workspace/lib/engine/signals/layout-paths.js +44 -0
- package/workspace/lib/engine/signals/options.js +24 -0
- package/workspace/lib/engine/signals/score.js +72 -0
- package/workspace/lib/engine/signals/select.js +47 -0
- package/workspace/lib/engine/step-planner.js +65 -0
- package/workspace/lib/engine/trust/claim-rules.json +93 -0
- package/workspace/lib/engine/trust/import-calendar.js +21 -0
- package/workspace/lib/engine/trust/import-guard.js +36 -0
- package/workspace/lib/engine/trust/ledger.js +53 -0
- package/workspace/lib/engine/trust/outbox.js +45 -0
- package/workspace/lib/engine/trust/plan.js +5 -0
- package/workspace/lib/engine/trust/trust.js +21 -0
- package/workspace/lib/engine/truth-table.js +57 -0
- package/workspace/lib/engine/workspace-lib.js +24 -0
- package/workspace/lib/factory-join.js +1 -1
- package/workspace/lib/facts-async.js +4 -4
- package/workspace/lib/headless.js +95 -0
- package/workspace/lib/import-overlay.js +2 -1
- package/workspace/lib/jira-status.js +30 -3
- package/workspace/lib/notes-audio.js +28 -9
- package/workspace/lib/notes-capture.js +136 -0
- package/workspace/lib/notes-final.js +38 -13
- package/workspace/lib/notes-live-guard.js +117 -0
- package/workspace/lib/notes-replay.js +125 -0
- package/workspace/lib/notes-screen.js +180 -0
- package/workspace/lib/notes-whisper.js +139 -16
- package/workspace/lib/notes.js +4 -2
- package/workspace/lib/outbox-store.js +10 -6
- package/workspace/lib/pmos-verbs.js +3 -1
- package/workspace/lib/proactive-cli.js +7 -0
- package/workspace/lib/proactive.js +15 -2
- package/workspace/lib/repo-script.js +36 -0
- package/workspace/lib/report-dashboards.js +5 -2
- package/workspace/lib/report-frame.js +122 -0
- package/workspace/lib/report-schedules.js +113 -0
- package/workspace/lib/route-modules.js +9 -2
- package/workspace/lib/routes-boards.js +734 -0
- package/workspace/lib/routes-brief-page.js +13 -4
- package/workspace/lib/routes-briefing.js +22 -11
- package/workspace/lib/routes-c3-init.js +4 -3
- package/workspace/lib/routes-c3-life.js +2 -1
- package/workspace/lib/routes-c3-reports.js +96 -13
- package/workspace/lib/routes-c3-strategy.js +4 -3
- package/workspace/lib/routes-c3-timeline.js +3 -2
- package/workspace/lib/routes-calendar-import.js +3 -2
- package/workspace/lib/routes-calendar-views.js +9 -4
- package/workspace/lib/routes-doc-health.js +2 -1
- package/workspace/lib/routes-doc-sheet.js +2 -1
- package/workspace/lib/routes-forecast.js +2 -1
- package/workspace/lib/routes-hooks.js +3 -2
- package/workspace/lib/routes-initiatives.js +2 -1
- package/workspace/lib/routes-intake.js +2 -1
- package/workspace/lib/routes-notes.js +113 -15
- package/workspace/lib/routes-outbox.js +23 -3
- package/workspace/lib/routes-pm-items.js +19 -6
- package/workspace/lib/routes-records.js +3 -2
- package/workspace/lib/routes-releases.js +2 -1
- package/workspace/lib/routes-report-facts.js +4 -3
- package/workspace/lib/routes-report-families.js +2 -1
- package/workspace/lib/routes-roadmap-ops.js +5 -4
- package/workspace/lib/routes-runs.js +6 -5
- package/workspace/lib/routes-settings.js +51 -0
- package/workspace/lib/routes-signals.js +3 -2
- package/workspace/lib/routes-stage-tools.js +220 -0
- package/workspace/lib/run-answers.js +2 -1
- package/workspace/lib/settings-store.js +226 -0
- package/workspace/lib/stage-tools.js +208 -0
- package/workspace/lib/vendor/scripts/signals/layout-paths.js +2 -1
- package/workspace/lib/vendor/scripts/workspace-lib.js +24 -0
- package/workspace/lib/work-evidence.js +73 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/briefing-deck.js +63 -17
- package/workspace/public/pm/briefing-model.js +36 -7
- package/workspace/public/pm/c3-ambient.js +100 -0
- package/workspace/public/pm/c3-brief-needs.js +26 -6
- package/workspace/public/pm/c3-brief-pulse.js +12 -4
- package/workspace/public/pm/c3-brief-today.js +56 -21
- package/workspace/public/pm/c3-brief.js +2 -1
- package/workspace/public/pm/c3-calendar.js +161 -42
- package/workspace/public/pm/c3-calpop.js +5 -5
- package/workspace/public/pm/c3-demo-boards.js +649 -0
- package/workspace/public/pm/c3-demo-brief-page.js +1 -1
- package/workspace/public/pm/c3-demo-cal4.js +3 -2
- package/workspace/public/pm/c3-demo-cal5.js +52 -0
- package/workspace/public/pm/c3-demo-data.js +17 -27
- package/workspace/public/pm/c3-demo-rep3.js +46 -5
- package/workspace/public/pm/c3-demo.js +2 -1
- package/workspace/public/pm/c3-dock.js +1 -1
- package/workspace/public/pm/c3-ib-docs.js +80 -0
- package/workspace/public/pm/c3-ib-initiatives.js +134 -0
- package/workspace/public/pm/c3-ib-items.js +201 -0
- package/workspace/public/pm/c3-ib-releases.js +51 -0
- package/workspace/public/pm/c3-ib-sprints.js +55 -0
- package/workspace/public/pm/c3-ib-triage.js +178 -0
- package/workspace/public/pm/c3-ii-tabs.js +240 -0
- package/workspace/public/pm/c3-ini-records.js +197 -0
- package/workspace/public/pm/c3-ini-timeline.js +278 -0
- package/workspace/public/pm/c3-ini-ui.js +229 -0
- package/workspace/public/pm/c3-ini-work.js +139 -0
- package/workspace/public/pm/c3-initiatives.css +218 -0
- package/workspace/public/pm/c3-initiatives.js +741 -0
- package/workspace/public/pm/c3-plan.js +2 -2
- package/workspace/public/pm/c3-reports.css +47 -47
- package/workspace/public/pm/c3-reports.js +223 -185
- package/workspace/public/pm/c3-settings.css +74 -0
- package/workspace/public/pm/c3-settings.js +703 -0
- package/workspace/public/pm/c3-shell.js +102 -36
- package/workspace/public/pm/c3-terminal-icons.js +2 -2
- package/workspace/public/pm/c3-terminal.js +2 -2
- package/workspace/public/pm/calendar-views.js +26 -13
- package/workspace/public/pm/components.css +319 -213
- package/workspace/public/pm/console3.css +118 -23
- package/workspace/public/pm/dest-notes.js +26 -4
- package/workspace/public/pm/notes-screen-capture.js +165 -0
- package/workspace/public/pm/notes-screen-view.js +98 -0
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/pm-work-view.js +233 -146
- package/workspace/public/pm/shell-bell.js +3 -2
- package/workspace/public/pm/shell-recording.js +148 -17
- package/workspace/public/pm/tokens.css +2 -2
- package/workspace/public/pm/view-notes.js +53 -11
- package/workspace/public/pm/welcome.js +197 -89
- package/workspace/public/welcome.html +6 -4
- package/workspace/server.js +105 -6
- package/workspace/skills/day-check.skill +1 -1
- package/workspace/skills/jira-sync.skill +6 -4
- package/workspace/skills/morning-brief.skill +1 -1
- package/workspace/test-files.json +14 -3
- package/workspace/test-lists/c3-ib-items.json +9 -0
- package/workspace/test-lists/c3-ib-more.json +9 -0
- package/workspace/test-lists/c3-ib-triage-backlog.json +9 -0
- package/workspace/test-lists/c3-initiative-boards.json +6 -0
- package/workspace/test-lists/calendar-tidy-up.json +6 -0
- package/workspace/test-lists/console3-briefing.json +9 -0
- package/workspace/test-lists/console3-foundation.json +9 -0
- package/workspace/test-lists/console3-reports.json +3 -3
- package/workspace/test-lists/meeting-notes.json +12 -4
- package/workspace/test-lists/workspace-headless-jobs.json +5 -0
- package/workspace/test-lists/workspace-package-script-closure.json +6 -0
- package/workspace/lib/vendor/workspace/lib/actor.js +0 -91
- package/workspace/lib/vendor/workspace/lib/board-cache.js +0 -156
- package/workspace/lib/vendor/workspace/lib/console-state.js +0 -295
- package/workspace/lib/vendor/workspace/lib/factory-join.js +0 -158
- package/workspace/lib/vendor/workspace/lib/git-info.js +0 -306
- package/workspace/lib/vendor/workspace/lib/plan-items.js +0 -922
- package/workspace/lib/vendor/workspace/lib/plan-tree.js +0 -419
- package/workspace/lib/vendor/workspace/lib/review-gate.js +0 -39
- package/workspace/lib/vendor/workspace/lib/sink.js +0 -321
- package/workspace/lib/vendor/workspace/lib/state-root.js +0 -587
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* plan-forecast.js — the forecast, critical path, time machine and capacity load of the Timeline (console3 wave B5).
|
|
3
|
+
* PURE: no I/O, no clock (today is a parameter). Nothing here is typed by the PM; every date is derived.
|
|
4
|
+
*
|
|
5
|
+
* BACKED BY THE PM'S SAMPLE: the forecast dates come from derived/plan.js plan() (end = latest dependency end, or the start,
|
|
6
|
+
* plus duration), and the critical path is derived/derive.js critical() (the items with slack 0 against the forecast, from
|
|
7
|
+
* the sample's facts.js). Nothing here re-implements them; this file only adapts repo items to the sample's state shape and
|
|
8
|
+
* keeps the graph clean (unknown dependencies and cycles are reported in `problems`, where plan.js throws).
|
|
9
|
+
* REPO-WRITTEN, because the sample has no equivalent: stateAt/replay (the slider stepping over { at, id, op } events),
|
|
10
|
+
* weeksFrom, progressOf, capacityLoad and rebalanceProposal.
|
|
11
|
+
*
|
|
12
|
+
* DECISION CHANGED TO MATCH THE SAMPLE: the older code moved an unfinished item past its planned end to today + remaining
|
|
13
|
+
* time (the handoff's open question, "decide at build"). plan.js decides otherwise: progress is not an input, so the forecast
|
|
14
|
+
* ignores status, progress, doneAt and today. `today` now only decides the 'overdue' status label.
|
|
15
|
+
*
|
|
16
|
+
* Items: { id, start:'YYYY-MM-DD', due:'YYYY-MM-DD', state|status, after:[ids], owner, points, skill, progress }.
|
|
17
|
+
* Dependencies: `deps` is { id: [ids it waits for] }, or null to read each item's `after`.
|
|
18
|
+
*/
|
|
19
|
+
const P = require('./derived/plan.js');
|
|
20
|
+
const V = require('./derived/derive.js');
|
|
21
|
+
const DAY = P.D;
|
|
22
|
+
const day = (s) => P.day(String(s).slice(0, 10));
|
|
23
|
+
const fmt = P.fmt;
|
|
24
|
+
const stateOf = (it) => String(it.state || it.status || 'todo');
|
|
25
|
+
const PROGRESS = { todo: 0, doing: 0.5, review: 0.9, done: 1 };
|
|
26
|
+
function progressOf(it) {
|
|
27
|
+
if (typeof it.progress === 'number' && it.progress >= 0 && it.progress <= 1 && stateOf(it) !== 'done') return it.progress;
|
|
28
|
+
return PROGRESS[stateOf(it)] == null ? 0 : PROGRESS[stateOf(it)];
|
|
29
|
+
}
|
|
30
|
+
const durOf = (it) => (it.start && it.due ? Math.max(1, Math.round((day(it.due) - day(it.start)) / DAY)) : 1);
|
|
31
|
+
const depsOf = (it, deps) => (deps && deps[it.id] ? deps[it.id] : (!deps && it.after) || []);
|
|
32
|
+
|
|
33
|
+
/** Clean the graph: unknown dependencies reported, cycles broken at the back edge and reported, topological order. */
|
|
34
|
+
function graph(items, deps) {
|
|
35
|
+
const by = new Map(items.map((it) => [it.id, it]));
|
|
36
|
+
const problems = [];
|
|
37
|
+
const clean = new Map();
|
|
38
|
+
for (const it of items) {
|
|
39
|
+
const ok = [];
|
|
40
|
+
for (const d of depsOf(it, deps)) {
|
|
41
|
+
if (!by.has(d)) problems.push({ kind: 'unknown-dependency', item: it.id, dependency: d });
|
|
42
|
+
else if (d === it.id) problems.push({ kind: 'cycle', path: [it.id, it.id] });
|
|
43
|
+
else ok.push(d);
|
|
44
|
+
}
|
|
45
|
+
clean.set(it.id, ok.sort());
|
|
46
|
+
}
|
|
47
|
+
const color = new Map();
|
|
48
|
+
const order = [];
|
|
49
|
+
const visit = (id, stack) => {
|
|
50
|
+
color.set(id, 1);
|
|
51
|
+
const keep = [];
|
|
52
|
+
for (const d of clean.get(id)) {
|
|
53
|
+
if (color.get(d) === 1) { problems.push({ kind: 'cycle', path: stack.slice(stack.indexOf(d)).concat(d) }); continue; }
|
|
54
|
+
keep.push(d);
|
|
55
|
+
if (!color.get(d)) visit(d, stack.concat(d));
|
|
56
|
+
}
|
|
57
|
+
clean.set(id, keep);
|
|
58
|
+
color.set(id, 2);
|
|
59
|
+
order.push(id);
|
|
60
|
+
};
|
|
61
|
+
for (const id of [...by.keys()].sort()) if (!color.get(id)) visit(id, [id]);
|
|
62
|
+
return { by, clean, order, problems };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function statusOf(it, slip, end, today) {
|
|
66
|
+
if (stateOf(it) !== 'done' && it.due && day(it.due) < today) return 'overdue';
|
|
67
|
+
if (slip <= 0) return 'on time';
|
|
68
|
+
return slip <= 3 ? 'at risk' : 'delayed';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The sample's state shape ({ dur, deps }) over the cleaned graph, and the one start plan.js takes: the earliest planned start. */
|
|
72
|
+
function sampleState(g, today) {
|
|
73
|
+
const state = {};
|
|
74
|
+
for (const [id, it] of g.by) state[id] = { dur: durOf(it), deps: g.clean.get(id) };
|
|
75
|
+
const starts = [...g.by.values()].filter((it) => it.start).map((it) => day(it.start));
|
|
76
|
+
return { state, start: fmt(starts.length ? Math.min(...starts) : day(today)) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** forecast(items, deps, today) → { rows: [{ id, start, end, slip_days, status }], problems }. `today` is 'YYYY-MM-DD'. */
|
|
80
|
+
function forecast(items, deps, today) {
|
|
81
|
+
const g = graph(items, deps);
|
|
82
|
+
if (!items.length) return { rows: [], problems: g.problems };
|
|
83
|
+
const t = day(today);
|
|
84
|
+
const { state, start } = sampleState(g, today);
|
|
85
|
+
const pl = P.plan(state, start);
|
|
86
|
+
const rows = items.map((it) => {
|
|
87
|
+
const e = pl.end[it.id];
|
|
88
|
+
const plannedEnd = it.due ? day(it.due) : e;
|
|
89
|
+
const slip = Math.round((e - plannedEnd) / DAY);
|
|
90
|
+
return { id: it.id, start: fmt(e - state[it.id].dur * DAY), end: fmt(e), slip_days: slip, status: statusOf(it, slip, e, t) };
|
|
91
|
+
});
|
|
92
|
+
return { rows, problems: g.problems };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** criticalPath(items, deps) → { ids, days, problems }: every item with slack 0 against the forecast (the sample's rule), in
|
|
96
|
+
* end-date then id order. Two equal chains are both critical. days = forecast minus the start. */
|
|
97
|
+
function criticalPath(items, deps) {
|
|
98
|
+
const g = graph(items, deps);
|
|
99
|
+
if (!items.length) return { ids: [], days: 0, problems: g.problems };
|
|
100
|
+
const { state, start } = sampleState(g, '1970-01-01');
|
|
101
|
+
const pl = P.plan(state, start);
|
|
102
|
+
const ids = [...V.critical(state, pl)].sort((a, b) => pl.end[a] - pl.end[b] || (a < b ? -1 : 1));
|
|
103
|
+
return { ids, days: Math.round((pl.forecast - day(start)) / DAY), problems: g.problems };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** stateAt(events, date) → the items as the fold of events up to and including `date` (the whole day). Events are append-only
|
|
107
|
+
* { at, id, op: 'add'|'set'|'remove', fields }; the same events in any order give the same state (sorted by at, then input order). */
|
|
108
|
+
function stateAt(events, date) {
|
|
109
|
+
const cut = /^\d{4}-\d\d-\d\d$/.test(String(date)) ? date + 'T23:59:59.999Z' : String(date);
|
|
110
|
+
const sorted = (events || []).map((e, i) => ({ e, i })).filter((x) => String(x.e.at) <= cut)
|
|
111
|
+
.sort((a, b) => (String(a.e.at) < String(b.e.at) ? -1 : String(a.e.at) > String(b.e.at) ? 1 : a.i - b.i));
|
|
112
|
+
const s = new Map();
|
|
113
|
+
for (const { e } of sorted) {
|
|
114
|
+
if (e.op === 'add') s.set(e.id, { ...(e.fields || {}), id: e.id });
|
|
115
|
+
else if (e.op === 'set' && s.has(e.id)) Object.assign(s.get(e.id), e.fields || {});
|
|
116
|
+
else if (e.op === 'remove') s.delete(e.id);
|
|
117
|
+
}
|
|
118
|
+
return [...s.values()].sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** replay(events, from, to, stepDays) → [{ date, items }] in date order, always ending at `to`. At most 400 steps. */
|
|
122
|
+
function replay(events, from, to, stepDays) {
|
|
123
|
+
const step = Math.max(1, Math.floor(stepDays) || 1) * DAY;
|
|
124
|
+
const out = [];
|
|
125
|
+
for (let d = day(from); d < day(to) && out.length < 399; d += step) out.push({ date: fmt(d), items: stateAt(events, fmt(d)) });
|
|
126
|
+
out.push({ date: fmt(day(to)), items: stateAt(events, to) });
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The n week starts (Mondays) from the week that holds `today`. */
|
|
131
|
+
function weeksFrom(today, n) {
|
|
132
|
+
const d = new Date(day(today));
|
|
133
|
+
const back = (d.getUTCDay() + 6) % 7;
|
|
134
|
+
return Array.from({ length: n }, (_, i) => fmt(day(today) - back * DAY + i * 7 * DAY));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** capacityLoad(items, people, weeks) → { weeks, people: [{ id, points_per_week, skills, weeks: [{ week, load, over, parts }] }], over }.
|
|
138
|
+
* weeks: Monday dates. An item's remaining points (points, default 1, x (1 - progress)) spread evenly over its start..end days
|
|
139
|
+
* (give it forecast rows' start and end); a week's load is the points falling in it. people: { id, points_per_week, skills }. */
|
|
140
|
+
function capacityLoad(items, people, weeks) {
|
|
141
|
+
const out = (people || []).map((p) => ({ id: p.id, points_per_week: p.points_per_week, skills: p.skills || [],
|
|
142
|
+
weeks: weeks.map((w) => ({ week: w, load: 0, over: false, parts: [] })) }));
|
|
143
|
+
const by = new Map(out.map((p) => [p.id, p]));
|
|
144
|
+
for (const it of items) {
|
|
145
|
+
const p = by.get(it.owner);
|
|
146
|
+
if (!p || stateOf(it) === 'done') continue;
|
|
147
|
+
const s = day(it.start || it.due); const e = Math.max(day(it.end || it.due), s + DAY);
|
|
148
|
+
const pts = (typeof it.points === 'number' ? it.points : 1) * (1 - progressOf(it));
|
|
149
|
+
p.weeks.forEach((w) => {
|
|
150
|
+
const a = Math.max(s, day(w.week)); const b = Math.min(e, day(w.week) + 7 * DAY);
|
|
151
|
+
if (b > a) { const part = pts * (b - a) / (e - s); w.load += part; w.parts.push({ item: it.id, points: part, skill: it.skill || null }); }
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const over = [];
|
|
155
|
+
for (const p of out) for (const w of p.weeks) {
|
|
156
|
+
w.load = Math.round(w.load * 100) / 100;
|
|
157
|
+
w.over = w.load > p.points_per_week;
|
|
158
|
+
if (w.over) over.push({ person: p.id, week: w.week, load: w.load, capacity: p.points_per_week });
|
|
159
|
+
}
|
|
160
|
+
return { weeks, people: out, over };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** rebalanceProposal(load) → { proposal: true, applied: false, moves }. For each over-capacity week (worst first) one item moves
|
|
164
|
+
* from the most overloaded person to the least loaded one who has the item's skill, else to the least loaded. A PROPOSAL only:
|
|
165
|
+
* this function changes nothing, and the plan changes only through the plan-items writer. */
|
|
166
|
+
function rebalanceProposal(load) {
|
|
167
|
+
const sim = new Map(load.people.map((p) => [p.id, new Map(p.weeks.map((w) => [w.week, w.load]))]));
|
|
168
|
+
const moves = [];
|
|
169
|
+
const todo = load.over.map((o) => ({ ...o, excess: o.load - o.capacity })).sort((a, b) => b.excess - a.excess || (a.person + a.week < b.person + b.week ? -1 : 1));
|
|
170
|
+
for (const o of todo) {
|
|
171
|
+
const from = load.people.find((p) => p.id === o.person);
|
|
172
|
+
const part = from.weeks.find((w) => w.week === o.week).parts.slice().sort((a, b) => b.points - a.points || (a.item < b.item ? -1 : 1))[0];
|
|
173
|
+
if (!part || moves.some((m) => m.item === part.item)) continue;
|
|
174
|
+
const others = load.people.filter((p) => p.id !== o.person);
|
|
175
|
+
const ratio = (p) => sim.get(p.id).get(o.week) / (p.points_per_week || 1);
|
|
176
|
+
const skilled = part.skill ? others.filter((p) => p.skills.includes(part.skill)) : [];
|
|
177
|
+
const pool = skilled.length ? skilled : others;
|
|
178
|
+
const to = pool.slice().sort((a, b) => ratio(a) - ratio(b) || (a.id < b.id ? -1 : 1))[0];
|
|
179
|
+
if (!to) continue;
|
|
180
|
+
sim.get(o.person).set(o.week, sim.get(o.person).get(o.week) - part.points);
|
|
181
|
+
sim.get(to.id).set(o.week, sim.get(to.id).get(o.week) + part.points);
|
|
182
|
+
moves.push({ item: part.item, from: o.person, to: to.id, week: o.week, points: Math.round(part.points * 100) / 100,
|
|
183
|
+
reason: skilled.length ? 'least loaded with skill ' + part.skill : 'least loaded' });
|
|
184
|
+
}
|
|
185
|
+
return { proposal: true, applied: false, moves };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
/* ───────────── the Timeline's per-check frames and "what changed" (console3 Timeline, ported from the mockup's tixAt / planOf / revsAt) ─────────────
|
|
190
|
+
* The mockup replays the plan by folding every ticket's revision history up to a check and re-planning it (tixAt, planOf). These do the same
|
|
191
|
+
* over the repo's { at, id, op, fields, by, why } events: stateAt() is the fold, plan.js plan() is the plan, and the one difference from
|
|
192
|
+
* forecast() is the day convention. A bar covers its first and LAST day (the mockup draws due + 1 as the bar's right edge), so a dependency
|
|
193
|
+
* arrow from the end of one bar meets the start of the next. plan.js treats `end` as the first day AFTER the work, so end(inclusive) = end - 1 day.
|
|
194
|
+
* An item with its own planned start keeps it: plan.js has one start for the whole plan, so each item gets a zero-dependency "gap" node as long as
|
|
195
|
+
* the days from the plan start to its own start (a node plan.js plans like any other; its rows are dropped again). Milestones take no time.
|
|
196
|
+
*/
|
|
197
|
+
const dayStr = (n) => fmt(n);
|
|
198
|
+
const addD = (s, n) => fmt(day(s) + n * DAY);
|
|
199
|
+
const gapId = (id) => ' gap:' + id;
|
|
200
|
+
|
|
201
|
+
/** frameForecast(items, today, opts?) → { rows: [{ id, start, end, slip_days, status }], critical: [ids], forecast_end, problems }.
|
|
202
|
+
* items: { id, start, due, after, state, ms? }. Dates are 'YYYY-MM-DD', end is the last day of work (inclusive). today only labels 'overdue'. */
|
|
203
|
+
function frameForecast(items, today) {
|
|
204
|
+
const g = graph(items, null);
|
|
205
|
+
if (!items.length) return { rows: [], critical: [], forecast_end: null, problems: g.problems };
|
|
206
|
+
const starts = items.filter((it) => it.start).map((it) => day(it.start));
|
|
207
|
+
const base = starts.length ? Math.min(...starts) : day(today);
|
|
208
|
+
const state = {};
|
|
209
|
+
for (const [id, it] of g.by) {
|
|
210
|
+
const ownStart = it.start ? day(it.start) : base;
|
|
211
|
+
const gap = Math.max(0, Math.round((ownStart - base) / DAY) + (it.ms ? 1 : 0));
|
|
212
|
+
const dur = it.ms ? 0 : (it.start && it.due ? Math.max(1, Math.round((day(it.due) - day(it.start)) / DAY) + 1) : 1);
|
|
213
|
+
state[gapId(id)] = { dur: gap, deps: [] };
|
|
214
|
+
state[id] = { dur, deps: stateOf(it) === 'done' ? [gapId(id)] : [gapId(id), ...g.clean.get(id)] }; // finished work keeps the dates it had
|
|
215
|
+
}
|
|
216
|
+
const pl = P.plan(state, fmt(base));
|
|
217
|
+
const t = day(today);
|
|
218
|
+
const rows = items.map((it) => {
|
|
219
|
+
const e = pl.end[it.id];
|
|
220
|
+
const endDay = e - DAY; // plan.js end is the first day after the work: the last day is one before
|
|
221
|
+
const due = it.due ? day(it.due) : endDay;
|
|
222
|
+
const slip = Math.max(0, Math.round((endDay - due) / DAY));
|
|
223
|
+
const startDay = it.ms ? endDay : e - state[it.id].dur * DAY;
|
|
224
|
+
const done = stateOf(it) === 'done';
|
|
225
|
+
const status = done ? 'done' : slip <= 0 ? 'on time' : due < t ? 'overdue' : slip <= 3 ? 'at risk' : 'delayed';
|
|
226
|
+
return { id: it.id, start: fmt(startDay), end: fmt(endDay), slip_days: done ? 0 : slip, status };
|
|
227
|
+
});
|
|
228
|
+
const crit = [...V.critical(state, pl)].filter((k) => !k.startsWith(' gap:') && stateOf(g.by.get(k)) !== 'done').sort((a, b) => pl.end[a] - pl.end[b] || (a < b ? -1 : 1));
|
|
229
|
+
return { rows, critical: crit, forecast_end: fmt(Math.max(...rows.map((r) => day(r.end)))), problems: g.problems };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const ptsOf = (it) => (typeof it.points === 'number' && it.points > 0 ? it.points : 1);
|
|
233
|
+
const sumPts = (items) => items.reduce((a, it) => a + ptsOf(it), 0);
|
|
234
|
+
const depsStr = (a) => (a || []).slice().sort().join(',');
|
|
235
|
+
|
|
236
|
+
/** whatChanged(a, b, events?) → what differs between two plan states, as the mockup's timeRight reads it: items added, removed, moved
|
|
237
|
+
* (start or due, with the days), dependency changes, points and the forecast. a and b are stateAt() arrays (a may be empty or null).
|
|
238
|
+
* events: the { at, id, op, fields, by, why } events between the two checks; the last one that touched an item names who and why. */
|
|
239
|
+
function whatChanged(a, b, events) {
|
|
240
|
+
const A = new Map((a || []).map((it) => [it.id, it]));
|
|
241
|
+
const B = new Map((b || []).map((it) => [it.id, it]));
|
|
242
|
+
const last = new Map();
|
|
243
|
+
for (const e of events || []) last.set(e.id, e);
|
|
244
|
+
const who = (id) => { const e = last.get(id) || {}; return { by: e.by || null, why: e.why || '' }; };
|
|
245
|
+
const adds = new Map();
|
|
246
|
+
for (const e of events || []) if (e.op === 'add' && !adds.has(e.id)) adds.set(e.id, e);
|
|
247
|
+
const out = { added: [], removed: [], moved: [], deps: [], points: { from: sumPts([...A.values()]), to: sumPts([...B.values()]) } };
|
|
248
|
+
out.points.delta = out.points.to - out.points.from;
|
|
249
|
+
for (const [id, it] of B) {
|
|
250
|
+
if (!A.has(id)) { const e = adds.get(id) || last.get(id) || {}; out.added.push({ id, title: it.title || id, points: ptsOf(it), by: e.by || null, why: e.why || '' }); continue; }
|
|
251
|
+
const o = A.get(id);
|
|
252
|
+
for (const f of ['start', 'due']) {
|
|
253
|
+
if (o[f] && it[f] && o[f] !== it[f]) out.moved.push({ id, title: it.title || id, field: f, from: o[f], to: it[f], days: Math.round((day(it[f]) - day(o[f])) / DAY), ...who(id) });
|
|
254
|
+
}
|
|
255
|
+
if (depsStr(o.after) !== depsStr(it.after)) {
|
|
256
|
+
const was = new Set(o.after || []); const now = new Set(it.after || []);
|
|
257
|
+
out.deps.push({ id, title: it.title || id, added: [...now].filter((d) => !was.has(d)), removed: [...was].filter((d) => !now.has(d)), ...who(id) });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
for (const [id, it] of A) if (!B.has(id)) out.removed.push({ id, title: it.title || id, ...who(id) });
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** pickChecks(events, today, max?) → [{ date, label }]: the days the plan changed (the first is the baseline), then today. At most `max`
|
|
265
|
+
* (default 6): the baseline plus the latest changes. Events marked `synthetic` (a source with no history, such as Jira) never make a check. */
|
|
266
|
+
function pickChecks(events, today, max) {
|
|
267
|
+
const cap = Math.max(2, max || 6);
|
|
268
|
+
const real = (events || []).filter((e) => !e.synthetic && e.at && String(e.at).slice(0, 10) <= today);
|
|
269
|
+
const dates = [...new Set(real.map((e) => String(e.at).slice(0, 10)))].sort();
|
|
270
|
+
let keep = dates;
|
|
271
|
+
const hasToday = dates.includes(today);
|
|
272
|
+
const room = cap - (hasToday ? 0 : 1);
|
|
273
|
+
if (dates.length > room) keep = [dates[0], ...dates.slice(dates.length - (room - 1))];
|
|
274
|
+
const titleOf = new Map();
|
|
275
|
+
for (const e of real) if (e.op === 'add' && e.fields && e.fields.title && !titleOf.has(e.id)) titleOf.set(e.id, e.fields.title);
|
|
276
|
+
const label = (d, i) => {
|
|
277
|
+
if (d === today) return 'Today';
|
|
278
|
+
if (i === 0) return 'Baseline';
|
|
279
|
+
const adds = real.filter((e) => String(e.at).slice(0, 10) === d && e.op === 'add');
|
|
280
|
+
const n = real.filter((e) => String(e.at).slice(0, 10) === d).length;
|
|
281
|
+
return adds.length === 1 && n === 1 ? short(titleOf.get(adds[0].id) || adds[0].id, 24) + ' added' : n + ' change' + (n === 1 ? '' : 's');
|
|
282
|
+
};
|
|
283
|
+
const out = keep.map((d, i) => ({ date: d, label: label(d, i) }));
|
|
284
|
+
if (!hasToday) out.push({ date: today, label: 'Today' });
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
const short = (t, n) => { t = String(t); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
291
|
+
const dlab = (s) => { const d = new Date(day(s)); return d.getUTCDate() + ' ' + MONTHS[d.getUTCMonth()]; };
|
|
292
|
+
/** One line per item touched between two checks: who (the last to touch it), why, and what changed in words ("Due 24 to 28 Oct"). */
|
|
293
|
+
function describeWindow(win, before, after) {
|
|
294
|
+
const B = new Map((before || []).map((it) => [it.id, it])); const Af = new Map((after || []).map((it) => [it.id, it]));
|
|
295
|
+
const order = []; const by = new Map();
|
|
296
|
+
for (const e of win) { if (!by.has(e.id)) { by.set(e.id, []); order.push(e.id); } by.get(e.id).push(e); }
|
|
297
|
+
return order.map((id) => {
|
|
298
|
+
const es = by.get(id); const last = es[es.length - 1]; const a = Af.get(id); const b = B.get(id);
|
|
299
|
+
const src = a || b || {};
|
|
300
|
+
const base = { id, key: src.key || null, title: src.title || id, origin: src.origin || null, by: last.by || null, why: (es.slice().reverse().find((x) => x.why) || {}).why || '', at: last.at };
|
|
301
|
+
if (!a) return { ...base, op: 'remove', tx: 'Removed' };
|
|
302
|
+
if (!b) return { ...base, op: 'add', tx: '' };
|
|
303
|
+
const t = [];
|
|
304
|
+
if (b.start !== a.start && b.start && a.start) t.push('Start ' + dlab(b.start) + ' to ' + dlab(a.start));
|
|
305
|
+
if (b.due !== a.due && b.due && a.due) t.push('Due ' + dlab(b.due) + ' to ' + dlab(a.due));
|
|
306
|
+
if (depsStr(b.after) !== depsStr(a.after)) {
|
|
307
|
+
const was = new Set(b.after || []); const add = (a.after || []).filter((d) => !was.has(d)); const gone = (b.after || []).filter((d) => !(a.after || []).includes(d));
|
|
308
|
+
if (add.length) t.push('Waits on ' + add.map((d) => (Af.get(d) && Af.get(d).key) || d).join(', '));
|
|
309
|
+
if (gone.length) t.push('No longer waits on ' + gone.map((d) => (B.get(d) && B.get(d).key) || d).join(', '));
|
|
310
|
+
}
|
|
311
|
+
if (b.owner !== a.owner) t.push('Owner ' + (b.owner || 'none') + ' to ' + (a.owner || 'none'));
|
|
312
|
+
if (b.state !== a.state) t.push('Now ' + a.state);
|
|
313
|
+
if (ptsOf(b) !== ptsOf(a)) t.push('Points ' + ptsOf(b) + ' to ' + ptsOf(a));
|
|
314
|
+
if (b.title !== a.title) t.push('Renamed');
|
|
315
|
+
return { ...base, op: 'edit', tx: t.join(', ') };
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** checkFrames(events, checks, today) → [{ check, date, label, items, critical, forecast_end, promised, slip_days, points, problems, changes, events }]
|
|
320
|
+
* Each frame is the whole plan as it stood at that check, planned with plan.js, so a replay redraws a Gantt per frame. promised: the due date of the
|
|
321
|
+
* latest milestone (null when no item is a milestone); the forecast date is that milestone's forecast end, or the latest end when there is none. */
|
|
322
|
+
function checkFrames(events, checks, today) {
|
|
323
|
+
const out = []; let prev = null;
|
|
324
|
+
checks.forEach((c, i) => {
|
|
325
|
+
const items = stateAt(events, c.date);
|
|
326
|
+
const asOf = c.date > today ? today : c.date;
|
|
327
|
+
const f = frameForecast(items, asOf);
|
|
328
|
+
const by = new Map(f.rows.map((r) => [r.id, r]));
|
|
329
|
+
const crit = new Set(f.critical);
|
|
330
|
+
const merged = items.map((it) => ({ ...it, forecast: by.get(it.id), critical: crit.has(it.id) }));
|
|
331
|
+
const ms = items.filter((it) => it.ms && it.due).sort((x, y) => (x.due < y.due ? 1 : x.due > y.due ? -1 : 0))[0];
|
|
332
|
+
const promised = ms ? ms.due : null;
|
|
333
|
+
const fe = ms ? by.get(ms.id).end : f.forecast_end;
|
|
334
|
+
const win = (events || []).filter((e) => !e.synthetic && String(e.at).slice(0, 10) <= c.date && (!prev || String(e.at).slice(0, 10) > prev.date));
|
|
335
|
+
out.push({ check: i, date: c.date, label: c.label, items: merged, critical: f.critical, forecast_end: fe, promised, slip_days: promised && fe ? Math.round((day(fe) - day(promised)) / DAY) : 0,
|
|
336
|
+
points: sumPts(items), problems: f.problems, changes: i === 0 ? null : whatChanged(prev.items, items, win), events: describeWindow(win, prev ? prev.items : [], items) });
|
|
337
|
+
prev = { date: c.date, items };
|
|
338
|
+
});
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
module.exports = { describeWindow, frameForecast, whatChanged, pickChecks, checkFrames, timeline: V.timeline, forecast, criticalPath, stateAt, replay, capacityLoad, rebalanceProposal, weeksFrom, progressOf, graph };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The promise ledger, rebased on the PM's ledger (scripts/trust/ledger.js): ULID ids, append-only, and the writer comes
|
|
3
|
+
// from the call path (an actor), never from a field. Events: kind 'promise' and kind 'kept'.
|
|
4
|
+
const L = require('./trust/ledger.js');
|
|
5
|
+
|
|
6
|
+
function recordPromise({ to, what, by_date, source_entry_hash } = {}) {
|
|
7
|
+
if (!to || !what) throw new Error('to and what required');
|
|
8
|
+
if (Number.isNaN(Date.parse(by_date))) throw new Error('by_date must be an ISO date');
|
|
9
|
+
return { kind: 'promise', to, what, by_date, source_entry_hash: source_entry_hash || null };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const keptEvent = (source_entry_hash) => ({ kind: 'kept', source_entry_hash });
|
|
13
|
+
|
|
14
|
+
// Append through the ledger: adds id, why and (from the actor) by/via. `actor` is required: L.actorFrom(...) or the server's UI actor.
|
|
15
|
+
function appendPromise(file, event, actor, now = new Date()) {
|
|
16
|
+
const why = event.kind === 'kept' ? 'promise kept' : 'promise to ' + event.to;
|
|
17
|
+
return L.append(file, { id: L.newId(), why, at: now.toISOString(), ...event }, actor);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const kindOf = (e) => e.kind || e.event; // older lines used `event`
|
|
21
|
+
|
|
22
|
+
// ledger: array of events. A promise is kept when a 'kept' event carries its source_entry_hash.
|
|
23
|
+
function brokenPromises(ledger = [], today = new Date()) {
|
|
24
|
+
const t = new Date(today).getTime();
|
|
25
|
+
const kept = new Set(ledger.filter((e) => kindOf(e) === 'kept').map((e) => e.source_entry_hash));
|
|
26
|
+
return ledger.filter((e) => kindOf(e) === 'promise' && Date.parse(e.by_date) < t && !kept.has(e.source_entry_hash));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { recordPromise, keptEvent, brokenPromises, appendPromise };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* Record kinds the console3 model lists as "remaining entities": ideas, hypotheses, the decision log, KR metrics, bets.
|
|
4
|
+
*
|
|
5
|
+
* FILES. In the real repo each kind is ONE append-only JSONL file: planning/records/<kind>.jsonl (ideas.jsonl,
|
|
6
|
+
* hypotheses.jsonl, decisions.jsonl, kr-metrics.jsonl, bets.jsonl). Nothing here creates those files in the repo; every
|
|
7
|
+
* function takes a `root` (tests pass os.tmpdir()). The prototype sample keeps ideas and hypotheses as Markdown; here
|
|
8
|
+
* they are events so one fold and one writer serve all five kinds (the Markdown form can be derived from the fold).
|
|
9
|
+
*
|
|
10
|
+
* EVENTS. Every line is written by scripts/trust/ledger.js append (the PM's validated writer; this file has no appender of
|
|
11
|
+
* its own). A line is { id (ULID), kind: 'add'|'edit'|'reading', rid, fields, why, by, via }.
|
|
12
|
+
* add creates record `rid` with `fields` edit merges `fields` into it (latest edit wins per field)
|
|
13
|
+
* reading (kr-metrics only) one actual reading { at, value } of the KR metric `rid`
|
|
14
|
+
* The actor comes from the call path (ledger.actorFrom), never from a field.
|
|
15
|
+
*
|
|
16
|
+
* LINKS. ideas, hypotheses, decisions and bets carry `initiative` (initiative id) and `parent_kr`; signals link by those.
|
|
17
|
+
*/
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const ledger = require('../trust/ledger.js');
|
|
21
|
+
|
|
22
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
23
|
+
const num = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
24
|
+
const text = (v) => typeof v === 'string' && v.trim() !== '';
|
|
25
|
+
const HORIZONS = ['now', 'next', 'later'];
|
|
26
|
+
|
|
27
|
+
// required: name -> predicate and the reason shown when it fails. `fix` rules run on the merged record.
|
|
28
|
+
const KINDS = {
|
|
29
|
+
ideas: { file: 'ideas.jsonl', required: { title: [text, 'title'], impact: [num, 'impact (number)'], effort: [(v) => num(v) && v > 0, 'effort (number above 0)'], confidence: [(v) => num(v) && v >= 0 && v <= 1, 'confidence (0 to 1)'] } },
|
|
30
|
+
hypotheses: {
|
|
31
|
+
file: 'hypotheses.jsonl',
|
|
32
|
+
required: { title: [text, 'title'], falsifier: [text, 'falsifier (what result would prove it wrong)'], review_by: [(v) => DATE.test(String(v)), 'review_by (YYYY-MM-DD)'] },
|
|
33
|
+
},
|
|
34
|
+
decisions: { file: 'decisions.jsonl', required: { case: [text, 'case'], chose: [text, 'chose'], why: [text, 'why'] } },
|
|
35
|
+
'kr-metrics': {
|
|
36
|
+
file: 'kr-metrics.jsonl',
|
|
37
|
+
required: { metric: [text, 'metric'], baseline: [num, 'baseline (number)'], target: [num, 'target (number)'], by: [(v) => DATE.test(String(v)), 'by (YYYY-MM-DD)'] },
|
|
38
|
+
},
|
|
39
|
+
bets: {
|
|
40
|
+
file: 'bets.jsonl',
|
|
41
|
+
required: { title: [text, 'title'], horizon: [(v) => HORIZONS.includes(v), 'horizon (now, next or later)'], rank: [(v) => Number.isInteger(v) && v >= 1, 'rank (whole number from 1)'] },
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
const kindNames = () => Object.keys(KINDS);
|
|
45
|
+
const spec = (kind) => { if (!KINDS[kind]) throw new Error('unknown record kind: ' + kind); return KINDS[kind]; };
|
|
46
|
+
const fileOf = (root, kind) => path.join(root, spec(kind).file);
|
|
47
|
+
|
|
48
|
+
/** validate(kind, fields) -> { ok, reasons[] }. `fields` is the whole record (an add) or the merged record (an edit). */
|
|
49
|
+
function validate(kind, fields) {
|
|
50
|
+
const reasons = [];
|
|
51
|
+
const f = fields && typeof fields === 'object' ? fields : {};
|
|
52
|
+
for (const [name, [ok, label]] of Object.entries(spec(kind).required)) {
|
|
53
|
+
if (!(name in f) || f[name] == null || !ok(f[name])) reasons.push('missing or invalid: ' + label);
|
|
54
|
+
}
|
|
55
|
+
if (kind === 'kr-metrics' && f.by && f.baseline === f.target && num(f.baseline)) reasons.push('target equals baseline');
|
|
56
|
+
return { ok: reasons.length === 0, reasons };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const readLines = (file) => { try { return ledger.read(file); } catch { return []; } };
|
|
60
|
+
|
|
61
|
+
/** fold(kind, lines) -> list of records, in first-add order. An edit merges; a reading joins `readings` (kr-metrics). */
|
|
62
|
+
function fold(kind, lines) {
|
|
63
|
+
spec(kind);
|
|
64
|
+
const by = new Map();
|
|
65
|
+
for (const e of lines) {
|
|
66
|
+
if (!e || !e.rid) continue;
|
|
67
|
+
if (e.kind === 'add') by.set(e.rid, { rid: e.rid, ...e.fields, created: e.id, updated: e.id, readings: [] });
|
|
68
|
+
else if (e.kind === 'edit' && by.has(e.rid)) Object.assign(by.get(e.rid), e.fields, { updated: e.id });
|
|
69
|
+
else if (e.kind === 'reading' && by.has(e.rid) && e.fields) by.get(e.rid).readings.push({ at: e.fields.at, value: e.fields.value });
|
|
70
|
+
}
|
|
71
|
+
const out = [...by.values()];
|
|
72
|
+
if (kind !== 'kr-metrics') out.forEach((r) => delete r.readings);
|
|
73
|
+
else out.forEach((r) => r.readings.sort((a, b) => String(a.at).localeCompare(String(b.at))));
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function list(root, kind) { return fold(kind, readLines(fileOf(root, kind))); }
|
|
78
|
+
|
|
79
|
+
/** The three writers each validate, then go through ledger.append. They return { ok, rid?, line?, reasons? }. */
|
|
80
|
+
function write(root, kind, ev, actor) {
|
|
81
|
+
fs.mkdirSync(root, { recursive: true });
|
|
82
|
+
const file = fileOf(root, kind);
|
|
83
|
+
const line = ledger.append(file, { id: ledger.newId(), ...ev }, actor);
|
|
84
|
+
return { ok: true, rid: ev.rid, line };
|
|
85
|
+
}
|
|
86
|
+
function add(root, kind, fields, actor, why) {
|
|
87
|
+
const v = validate(kind, fields);
|
|
88
|
+
if (!v.ok) return { ok: false, reasons: v.reasons };
|
|
89
|
+
const rid = (fields && fields.rid) || kind.replace(/s$/, '').replace('kr-metric', 'krm') + '-' + ledger.newId().slice(-8).toLowerCase();
|
|
90
|
+
const { rid: _r, readings: _x, ...clean } = fields;
|
|
91
|
+
return write(root, kind, { kind: 'add', rid, fields: clean, why: why || 'added' }, actor);
|
|
92
|
+
}
|
|
93
|
+
function edit(root, kind, rid, fields, actor, why) {
|
|
94
|
+
const cur = list(root, kind).find((r) => r.rid === rid);
|
|
95
|
+
if (!cur) return { ok: false, reasons: ['no such record: ' + rid] };
|
|
96
|
+
const merged = { ...cur, ...fields };
|
|
97
|
+
const v = validate(kind, merged);
|
|
98
|
+
if (!v.ok) return { ok: false, reasons: v.reasons };
|
|
99
|
+
return write(root, kind, { kind: 'edit', rid, fields, why: why || 'edited' }, actor);
|
|
100
|
+
}
|
|
101
|
+
function addReading(root, rid, reading, actor, why) {
|
|
102
|
+
if (!reading || !DATE.test(String(reading.at)) || !num(reading.value)) return { ok: false, reasons: ['a reading needs at (YYYY-MM-DD) and value (number)'] };
|
|
103
|
+
if (!list(root, 'kr-metrics').some((r) => r.rid === rid)) return { ok: false, reasons: ['no such KR metric: ' + rid] };
|
|
104
|
+
return write(root, 'kr-metrics', { kind: 'reading', rid, fields: { at: reading.at, value: reading.value }, why: why || 'reading' }, actor);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** All five folds for nineStages and readRepoFacts. Missing files give empty lists. */
|
|
108
|
+
function readAll(root) {
|
|
109
|
+
const out = {};
|
|
110
|
+
for (const k of kindNames()) out[k] = list(root, k);
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { KINDS, HORIZONS, kindNames, validate, fold, list, add, edit, addReading, readAll, fileOf, readLines };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Run 5: releases, read side. Pure functions; appendRelease is the only I/O.
|
|
3
|
+
// PMOS never reads Jira: tickets and tags arrive as imports (jira-status.js normaliser) and are passed in.
|
|
4
|
+
// Tickets: { key, initiative_id?, fixVersion? }. fixVersion is a list of names (jira-status.js carries it) or one name.
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
|
|
8
|
+
const TYPES = ['deploy', 'customer', 'both'];
|
|
9
|
+
const LABELS = ['fact', 'estimate', 'assumption', 'reported'];
|
|
10
|
+
|
|
11
|
+
const isoOrNull = (v) => (typeof v === 'string' && !Number.isNaN(Date.parse(v)) ? v : null);
|
|
12
|
+
|
|
13
|
+
function newRelease({ id, initiative_ids = [], type, deploy_at = null, release_at = null } = {}) {
|
|
14
|
+
if (!id) throw new Error('release id required');
|
|
15
|
+
if (!TYPES.includes(type)) throw new Error(`type must be one of ${TYPES.join('|')}`);
|
|
16
|
+
return {
|
|
17
|
+
id, initiative_ids: [...initiative_ids], type,
|
|
18
|
+
deploy_at: isoOrNull(deploy_at), release_at: isoOrNull(release_at),
|
|
19
|
+
acceptance_required_before_tag: true, // a tag can only be created after the PM's acceptance (field only; creation not built)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The Jira tag naming convention is unknown, so this never guesses a pattern.
|
|
24
|
+
function detectTags(release, board) {
|
|
25
|
+
if (!board) return { detected: [], reason: 'board not set' };
|
|
26
|
+
const tags = (board.tags || []);
|
|
27
|
+
const detected = tags.filter((t) => t === release.id || (board.map && board.map[t] === release.id));
|
|
28
|
+
return { detected, reason: detected.length ? 'matched by release id or board.map' : 'no tag matched' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const tagsOf = (t) => (Array.isArray(t.fixVersion) ? t.fixVersion : t.fixVersion ? [t.fixVersion] : []);
|
|
32
|
+
|
|
33
|
+
function gapFlags(release, tickets = []) {
|
|
34
|
+
const ids = new Set(release.initiative_ids);
|
|
35
|
+
const tag = release.fix_version || release.id;
|
|
36
|
+
const flags = [];
|
|
37
|
+
for (const t of tickets) {
|
|
38
|
+
const tags = tagsOf(t);
|
|
39
|
+
if (ids.has(t.initiative_id) && !tags.length) flags.push({ flag: 'initiative_ticket_without_tag', ticket: t.key });
|
|
40
|
+
if (tags.includes(tag) && !t.initiative_id) flags.push({ flag: 'tagged_ticket_without_initiative', ticket: t.key });
|
|
41
|
+
}
|
|
42
|
+
return flags;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function citedSummary(lines = []) {
|
|
46
|
+
const refused = [];
|
|
47
|
+
lines.forEach((l, i) => {
|
|
48
|
+
const src = l && l.source;
|
|
49
|
+
const ok = typeof src === 'string' && (LABELS.includes(src) || /^[A-Z][A-Z0-9]+-\d+$/.test(src));
|
|
50
|
+
if (!ok) refused.push(i);
|
|
51
|
+
});
|
|
52
|
+
return refused.length ? { ok: false, refused } : { ok: true, lines };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// rule: null (default) or { start, end } ISO strings. No rule exists today.
|
|
56
|
+
function freezeWarning(release, rule = null) {
|
|
57
|
+
if (!rule) return null;
|
|
58
|
+
const s = Date.parse(rule.start), e = Date.parse(rule.end);
|
|
59
|
+
const hits = ['deploy_at', 'release_at'].filter((k) => {
|
|
60
|
+
const d = release[k] && Date.parse(release[k]);
|
|
61
|
+
return d && d >= s && d <= e;
|
|
62
|
+
});
|
|
63
|
+
return hits.length ? { warning: 'inside change freeze', fields: hits, window: { start: rule.start, end: rule.end } } : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function appendRelease(file, event, now = new Date()) {
|
|
67
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
68
|
+
fs.appendFileSync(file, JSON.stringify({ ...event, at: now.toISOString() }) + '\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Fold the append-only release lines (objects or JSON strings) into the current releases, in creation order.
|
|
72
|
+
// Events: created {id, ...newRelease fields}, edited {id, fields}, summary {id, lines}, deleted {id}.
|
|
73
|
+
const EDITABLE = ['initiative_ids', 'type', 'deploy_at', 'release_at', 'fix_version', 'accepted'];
|
|
74
|
+
function foldReleases(lines = []) {
|
|
75
|
+
const byId = new Map();
|
|
76
|
+
for (let l of lines) {
|
|
77
|
+
if (typeof l === 'string') { try { l = JSON.parse(l); } catch { continue; } }
|
|
78
|
+
if (!l || !l.id) continue;
|
|
79
|
+
if (l.event === 'created') {
|
|
80
|
+
const { event, at, ...rest } = l;
|
|
81
|
+
byId.set(l.id, { ...rest, summary: [], created_at: at || null, accepted_at: null });
|
|
82
|
+
} else if (l.event === 'deleted') byId.delete(l.id);
|
|
83
|
+
else if (byId.has(l.id) && l.event === 'edited') {
|
|
84
|
+
const r = byId.get(l.id);
|
|
85
|
+
for (const k of EDITABLE) {
|
|
86
|
+
if (!l.fields || !(k in l.fields)) continue;
|
|
87
|
+
if (k === 'accepted') r.accepted_at = l.fields.accepted ? (l.at || new Date(0).toISOString()) : null;
|
|
88
|
+
else if (k === 'deploy_at' || k === 'release_at') r[k] = isoOrNull(l.fields[k]);
|
|
89
|
+
else r[k] = l.fields[k];
|
|
90
|
+
}
|
|
91
|
+
} else if (byId.has(l.id) && l.event === 'summary') byId.get(l.id).summary = Array.isArray(l.lines) ? l.lines : [];
|
|
92
|
+
}
|
|
93
|
+
return [...byId.values()];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { TYPES, LABELS, EDITABLE, newRelease, detectTags, gapFlags, citedSummary, freezeWarning, appendRelease, foldReleases };
|