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,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* breakdown-ops.js — Breakdown is PMOS-only, non-development work (console3 wave E1b; HANDOFF 14f). Tickets are Jira only.
|
|
4
|
+
*
|
|
5
|
+
* Three phases (discovery, design, launch); each item has a source (local, figma, calendar, gmail, notes), an owner and points.
|
|
6
|
+
* An item can wait on a Jira ticket: a `dependency` event holds the ticket key, and planInput() turns the items into the
|
|
7
|
+
* { id: { deps, dur } } shape that scripts/derived/plan.js plan() reads (so release notes wait on the audit ticket).
|
|
8
|
+
*
|
|
9
|
+
* STORE. One append-only JSONL, <root>/breakdown-pmos.jsonl, written only through scripts/trust/ledger.js append. The writer
|
|
10
|
+
* is apply(); every other function is pure. Lines: { id (ULID), kind: add|edit|delete|undo|dependency, rid, fields?, snapshot?,
|
|
11
|
+
* corrects?, why, at, by, via }. `by` and `via` come from the call path (the ledger), never from a caller.
|
|
12
|
+
*
|
|
13
|
+
* JIRA IS READ-ONLY HERE. Jira-owned fields (title, owner, status) of an item a Jira ticket represents change only as a
|
|
14
|
+
* PROPOSAL ({ proposal:true }, no event). Such an item cannot be deleted here: the answer is unlink advice. Jira engineering
|
|
15
|
+
* load enters capacityLoad() as data and is returned as data; no function writes it.
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const ledger = require('./trust/ledger.js');
|
|
20
|
+
|
|
21
|
+
const PHASES = ['discovery', 'design', 'launch'];
|
|
22
|
+
const SOURCES = ['local', 'figma', 'calendar', 'gmail', 'notes'];
|
|
23
|
+
const JIRA_OWNED = ['title', 'owner', 'status'];
|
|
24
|
+
const UNDO_MS = 8000;
|
|
25
|
+
const DEFAULT_PPW = 5; // same default as workspace/lib/routes-forecast.js for an owner with no entry
|
|
26
|
+
const JIRA_KEY = /^[A-Z][A-Z0-9]+-\d+$/;
|
|
27
|
+
const FILE = 'breakdown-pmos.jsonl';
|
|
28
|
+
|
|
29
|
+
const refuse = (error, extra) => ({ ok: false, error, ...(extra || {}) });
|
|
30
|
+
const pts = (v) => typeof v === 'number' && Number.isFinite(v) && v > 0;
|
|
31
|
+
const fileOf = (root) => path.join(root, FILE);
|
|
32
|
+
const readLines = (root) => { try { return ledger.read(fileOf(root)); } catch { return []; } };
|
|
33
|
+
|
|
34
|
+
/** fold(lines) -> items in first-add order. delete removes, undo restores the snapshot, dependency edits `deps` (Jira keys). */
|
|
35
|
+
function fold(lines) {
|
|
36
|
+
const by = new Map();
|
|
37
|
+
const deletes = new Map();
|
|
38
|
+
for (const e of lines) {
|
|
39
|
+
if (!e || !e.rid) continue;
|
|
40
|
+
if (e.kind === 'add') by.set(e.rid, { rid: e.rid, deps: [], ...e.fields });
|
|
41
|
+
else if (e.kind === 'edit' && by.has(e.rid)) Object.assign(by.get(e.rid), e.fields);
|
|
42
|
+
else if (e.kind === 'dependency' && by.has(e.rid) && e.fields && e.fields.jira) {
|
|
43
|
+
const it = by.get(e.rid), k = e.fields.jira;
|
|
44
|
+
it.deps = e.fields.remove ? it.deps.filter((d) => d !== k) : [...new Set([...it.deps, k])];
|
|
45
|
+
} else if (e.kind === 'delete' && by.has(e.rid)) { deletes.set(e.id, e.snapshot || by.get(e.rid)); by.delete(e.rid); }
|
|
46
|
+
else if (e.kind === 'undo' && deletes.has(e.corrects)) by.set(e.rid, { ...deletes.get(e.corrects) });
|
|
47
|
+
}
|
|
48
|
+
return [...by.values()];
|
|
49
|
+
}
|
|
50
|
+
const list = (root) => fold(readLines(root));
|
|
51
|
+
|
|
52
|
+
function checkFields(f, { partial }) {
|
|
53
|
+
if ('title' in f || !partial) if (!String(f.title || '').trim()) return 'an item needs a title';
|
|
54
|
+
if ('phase' in f || !partial) if (!PHASES.includes(f.phase)) return 'phase must be discovery, design or launch';
|
|
55
|
+
if ('source' in f) {
|
|
56
|
+
if (String(f.source).toLowerCase() === 'jira') return 'Jira tickets live in Tickets; Breakdown holds PMOS-only work';
|
|
57
|
+
if (!SOURCES.includes(f.source)) return 'source must be local, figma, calendar, gmail or notes';
|
|
58
|
+
}
|
|
59
|
+
if ('points' in f && f.points !== null && !pts(f.points)) return 'points must be a number above 0';
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const newRid = () => 'bd-' + ledger.newId().slice(-8).toLowerCase();
|
|
64
|
+
|
|
65
|
+
/** addItem(items, { title, phase, source?, owner?, points?, initiative?, jira_key? }) -> { ok, rid, events } */
|
|
66
|
+
function addItem(items, input) {
|
|
67
|
+
const i = input || {};
|
|
68
|
+
const fields = { title: i.title, phase: i.phase, source: i.source || 'local' };
|
|
69
|
+
const err = checkFields({ ...fields, ...(i.points != null ? { points: i.points } : {}) }, { partial: false });
|
|
70
|
+
if (err) return refuse(err);
|
|
71
|
+
fields.title = String(i.title).trim();
|
|
72
|
+
for (const k of ['owner', 'points', 'initiative', 'jira_key']) if (i[k] != null && i[k] !== '') fields[k] = i[k];
|
|
73
|
+
if (fields.jira_key && !JIRA_KEY.test(fields.jira_key)) return refuse('jira_key must look like ABC-123');
|
|
74
|
+
const rid = i.rid || newRid();
|
|
75
|
+
if ((items || []).some((x) => x.rid === rid)) return refuse('item exists: ' + rid);
|
|
76
|
+
return { ok: true, rid, events: [{ kind: 'add', rid, fields, why: 'item added' }] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* editItem(items, rid, fields) -> { ok, events, proposal?, proposals? }
|
|
81
|
+
* title, owner and status of an item a Jira ticket represents are Jira's: they come back as { proposal:true, proposals:[...] }
|
|
82
|
+
* with NO event. The other fields (phase, points, source) still apply.
|
|
83
|
+
*/
|
|
84
|
+
function editItem(items, rid, fields) {
|
|
85
|
+
const it = (items || []).find((x) => x.rid === rid);
|
|
86
|
+
if (!it) return refuse('no such item: ' + rid);
|
|
87
|
+
const f = { ...(fields || {}) };
|
|
88
|
+
delete f.rid; delete f.deps;
|
|
89
|
+
const out = { ...f };
|
|
90
|
+
const proposals = [];
|
|
91
|
+
if (it.jira_key) {
|
|
92
|
+
for (const k of [...JIRA_OWNED, 'jira_key']) {
|
|
93
|
+
if (k in out && out[k] !== it[k]) { proposals.push({ field: k, from: it[k] === undefined ? null : it[k], to: out[k] }); }
|
|
94
|
+
delete out[k];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const err = checkFields(out, { partial: true });
|
|
98
|
+
if (err) return refuse(err);
|
|
99
|
+
const keys = Object.keys(out).filter((k) => out[k] !== it[k]);
|
|
100
|
+
const events = keys.length ? [{ kind: 'edit', rid, fields: Object.fromEntries(keys.map((k) => [k, out[k]])), why: 'edited ' + keys.join(', ') }] : [];
|
|
101
|
+
if (!events.length && !proposals.length) return refuse('nothing to change');
|
|
102
|
+
return { ok: true, events, ...(proposals.length ? { proposal: true, proposals, note: 'Jira owns these fields; the change is a proposal the PM sends by slide, nothing was applied' } : {}) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** deleteItem(items, rid, { now }) -> a delete event with a snapshot; undo for UNDO_MS. A Jira-backed item gets unlink advice. */
|
|
106
|
+
function deleteItem(items, rid, opts = {}) {
|
|
107
|
+
const it = (items || []).find((x) => x.rid === rid);
|
|
108
|
+
if (!it) return refuse('no such item: ' + rid);
|
|
109
|
+
if (it.jira_key) return refuse('a Jira ticket represents this item, so it cannot be deleted here', { jira_backed: true, advice: `Unlink ${it.jira_key} from the item, or close the ticket in Jira; Breakdown does not delete what Jira holds` });
|
|
110
|
+
const now = opts.now || new Date();
|
|
111
|
+
return { ok: true, events: [{ kind: 'delete', rid, snapshot: { ...it }, why: 'item deleted', at: now.toISOString() }], undo_ms: UNDO_MS };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** undoEvent(lines, rid, now) -> { ok, events } while the last delete of rid is younger than 8 seconds; else a refusal. */
|
|
115
|
+
function undoEvent(lines, rid, now = new Date()) {
|
|
116
|
+
const del = [...lines].reverse().find((e) => e && e.rid === rid && (e.kind === 'delete' || e.kind === 'undo'));
|
|
117
|
+
if (!del || del.kind === 'undo') return refuse('nothing to undo for ' + rid);
|
|
118
|
+
if (now.getTime() - new Date(del.at).getTime() > UNDO_MS) return refuse('the undo window (8 seconds) has passed', { expired: true });
|
|
119
|
+
return { ok: true, events: [{ kind: 'undo', rid, corrects: del.id, why: 'delete undone' }] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** setDependency(items, rid, jiraKey, { remove }) -> a dependency event; the item then waits on that ticket. */
|
|
123
|
+
function setDependency(items, rid, jiraKey, opts = {}) {
|
|
124
|
+
const it = (items || []).find((x) => x.rid === rid);
|
|
125
|
+
if (!it) return refuse('no such item: ' + rid);
|
|
126
|
+
if (!JIRA_KEY.test(String(jiraKey || ''))) return refuse('a dependency is a Jira ticket key such as ABC-123');
|
|
127
|
+
const has = it.deps.includes(jiraKey);
|
|
128
|
+
if (opts.remove ? !has : has) return refuse(opts.remove ? 'no such dependency' : 'already depends on ' + jiraKey);
|
|
129
|
+
return { ok: true, events: [{ kind: 'dependency', rid, fields: { jira: jiraKey, ...(opts.remove ? { remove: true } : {}) }, why: (opts.remove ? 'stopped waiting on ' : 'waits on ') + jiraKey }] };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* planInput(items, jira, { daysPerPoint }) -> { id: { deps, dur } } for scripts/derived/plan.js plan(). `jira` is read-only data,
|
|
134
|
+
* { 'ABC-1': { days, done } }. A Jira key an item waits on becomes a node with the ticket's remaining days (0 when done or unknown).
|
|
135
|
+
*/
|
|
136
|
+
function planInput(items, jira, opts = {}) {
|
|
137
|
+
const dpp = opts.daysPerPoint || 1;
|
|
138
|
+
const s = {};
|
|
139
|
+
for (const it of items || []) {
|
|
140
|
+
s[it.rid] = { deps: [...it.deps], dur: it.done ? 0 : (it.points || 1) * dpp };
|
|
141
|
+
for (const k of it.deps) if (!s[k]) { const j = (jira || {})[k] || {}; s[k] = { deps: [], dur: j.done ? 0 : Number(j.days) || 0 }; }
|
|
142
|
+
}
|
|
143
|
+
return s;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* capacityLoad(items, people, weeks, jiraLoad) -> [{ person, pmos_points, capacity, jira_points, over, free }]
|
|
148
|
+
* Compares the PMOS points of each owner to points_per_week * weeks. jiraLoad ({ person: points }) is engineering load, shown
|
|
149
|
+
* read-only and never subtracted or written. Unassigned points come back as person null.
|
|
150
|
+
*/
|
|
151
|
+
function capacityLoad(items, people, weeks = 2, jiraLoad = {}) {
|
|
152
|
+
const jira = Object.freeze({ ...jiraLoad });
|
|
153
|
+
const ppw = new Map((people || []).map((p) => [p.id, Number(p.points_per_week) || DEFAULT_PPW]));
|
|
154
|
+
const load = new Map();
|
|
155
|
+
for (const it of items || []) {
|
|
156
|
+
if (it.done) continue;
|
|
157
|
+
const who = it.owner || null;
|
|
158
|
+
load.set(who, (load.get(who) || 0) + (Number(it.points) || 1));
|
|
159
|
+
}
|
|
160
|
+
const ids = [...new Set([...ppw.keys(), ...[...load.keys()].filter(Boolean)])];
|
|
161
|
+
const rows = ids.map((person) => {
|
|
162
|
+
const capacity = (ppw.get(person) || DEFAULT_PPW) * weeks, used = load.get(person) || 0;
|
|
163
|
+
return { person, pmos_points: used, capacity, jira_points: jira[person] || 0, over: used > capacity, free: Math.max(0, capacity - used) };
|
|
164
|
+
});
|
|
165
|
+
if (load.has(null)) rows.push({ person: null, pmos_points: load.get(null), capacity: 0, jira_points: 0, over: false, free: 0, unassigned: true });
|
|
166
|
+
return rows;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** apply(root, events, actor, { now }) -> { ok, written } | { ok:false, error }. The one writer; ledger.append validates each line. */
|
|
170
|
+
function apply(root, events, actor, opts = {}) {
|
|
171
|
+
const now = opts.now || new Date();
|
|
172
|
+
fs.mkdirSync(root, { recursive: true });
|
|
173
|
+
const written = [];
|
|
174
|
+
try {
|
|
175
|
+
for (const e of events) written.push(ledger.append(fileOf(root), { id: ledger.newId(), at: now.toISOString(), ...e }, actor));
|
|
176
|
+
} catch (err) { return { ok: false, error: err.message, written }; }
|
|
177
|
+
return { ok: true, written };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = { PHASES, SOURCES, JIRA_OWNED, UNDO_MS, FILE, fileOf, readLines, fold, list, addItem, editItem, deleteItem, undoEvent, setDependency, planInput, capacityLoad, apply };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* calendar-import.js — cached calendar metadata, supplied by a caller (console3 wave E2a).
|
|
3
|
+
*
|
|
4
|
+
* PMOS holds no calendar credentials. An agent session with the PM's own connector fetches the events and hands them
|
|
5
|
+
* here; nothing in this file calls a network. An import is untrusted data (scripts/trust/import-guard.js): hidden text
|
|
6
|
+
* is stripped, every line is datamarked, the raw text is hashed, and the only claim an import can make is 'reported'.
|
|
7
|
+
*
|
|
8
|
+
* importEvents(rawEvents, { fetchedAt, sensitivity }) → [event] pure; a missing or unknown sensitivity is refused
|
|
9
|
+
* foldEvents(lines) → [event] current events; the PM's notes survive a refresh
|
|
10
|
+
* appendImport(file, events, actor, now) → lines ledger append (scripts/trust/ledger.js)
|
|
11
|
+
* appendNote(file, eventId, text, actor, now) → line a PM note on one event
|
|
12
|
+
* readLines(file) · cacheMarkdown(events, dir, now) the Markdown cache is the PM's trust/import-calendar.js, unchanged
|
|
13
|
+
*/
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const crypto = require('crypto');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const guard = require('./trust/import-guard.js');
|
|
18
|
+
const L = require('./trust/ledger.js');
|
|
19
|
+
const sampleCache = require('./trust/import-calendar.js');
|
|
20
|
+
|
|
21
|
+
const SENSITIVITY = Object.freeze(['public', 'internal', 'confidential', 'private']);
|
|
22
|
+
const HIDDEN = [/<!--[\s\S]*?-->/g, /[-]/g, /[\u{E0000}-\u{E007F}]/gu];
|
|
23
|
+
|
|
24
|
+
const strip = (s) => HIDDEN.reduce((t, re) => t.replace(re, ''), String(s == null ? '' : s));
|
|
25
|
+
const mark = (s) => s.split('\n').map((l) => '▒ ' + l).join('\n');
|
|
26
|
+
const nameOf = (a) => (typeof a === 'string' ? a : (a && (a.name || a.displayName)) || '');
|
|
27
|
+
|
|
28
|
+
function importEvents(rawEvents, opts = {}) {
|
|
29
|
+
if (!SENSITIVITY.includes(opts.sensitivity)) throw new Error('an import needs a sensitivity label: ' + SENSITIVITY.join(', '));
|
|
30
|
+
if (!Array.isArray(rawEvents)) throw new Error('events must be a list');
|
|
31
|
+
const fetchedAt = opts.fetchedAt || new Date().toISOString();
|
|
32
|
+
return rawEvents.map((e, i) => {
|
|
33
|
+
const at = e && (e.at || e.start);
|
|
34
|
+
if (!e || !e.id || !e.title || !at) throw new Error(`event ${i}: id, title and start are required`);
|
|
35
|
+
const attendeesRaw = (e.attendees || []).map(nameOf).filter(Boolean);
|
|
36
|
+
const rawText = [e.title, ...attendeesRaw].join('\n');
|
|
37
|
+
const findings = guard.scan(rawText);
|
|
38
|
+
const title = strip(e.title).replace(/\s+/g, ' ').trim();
|
|
39
|
+
const attendees = attendeesRaw.map((a) => strip(a).replace(/\s+/g, ' ').trim());
|
|
40
|
+
return {
|
|
41
|
+
id: String(e.id), at: String(at), end: e.end ? String(e.end) : null, title, attendees,
|
|
42
|
+
initiative: e.initiative ? String(e.initiative) : null, agent_prep_notes: '',
|
|
43
|
+
fetched_at: fetchedAt, external: true, sensitivity: opts.sensitivity,
|
|
44
|
+
label: 'reported', trust: 'untrusted', marked: mark([title, ...attendees].join('\n')),
|
|
45
|
+
findings, raw_sha256: crypto.createHash('sha256').update(rawText).digest('hex'),
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Lines: { kind:'calendar_event', ...event } replaces the cached part; { kind:'calendar_note', event_id, text } adds a PM note.
|
|
51
|
+
function foldEvents(lines) {
|
|
52
|
+
const by = new Map();
|
|
53
|
+
for (const l of lines || []) {
|
|
54
|
+
if (!l) continue;
|
|
55
|
+
if (l.kind === 'calendar_event' && l.event && l.event.id) {
|
|
56
|
+
const prev = by.get(l.event.id);
|
|
57
|
+
by.set(l.event.id, { ...l.event, agent_prep_notes: prev ? prev.agent_prep_notes : l.event.agent_prep_notes || '', pm_notes: prev ? prev.pm_notes : [] });
|
|
58
|
+
} else if (l.kind === 'calendar_note' && by.has(l.event_id)) {
|
|
59
|
+
by.get(l.event_id).pm_notes.push({ text: l.text, at: l.at, id: l.id });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [...by.values()].sort((a, b) => String(a.at).localeCompare(String(b.at)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const readLines = (file) => L.read(file);
|
|
66
|
+
|
|
67
|
+
function appendImport(file, events, actor, now) {
|
|
68
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
69
|
+
return events.map((event) => L.append(file, { id: L.newId(), kind: 'calendar_event', why: 'calendar import (reported, untrusted)', at: now || new Date().toISOString(), event }, actor));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function appendNote(file, eventId, text, actor, now) {
|
|
73
|
+
const t = String(text || '').trim();
|
|
74
|
+
if (!t) throw new Error('the note needs text');
|
|
75
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
76
|
+
return L.append(file, { id: L.newId(), kind: 'calendar_note', why: 'PM note on an imported event', event_id: eventId, text: t, at: now || new Date().toISOString() }, actor);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The PM's Markdown cache writer, unchanged; events are mapped to its shape. (It labels files 'internal'; the ledger holds the real label.)
|
|
80
|
+
function cacheMarkdown(events, dir, now) {
|
|
81
|
+
return sampleCache.importEvents(events.map((e) => ({ id: e.id, title: e.title, start: e.at, end: e.end || e.at, attendees: e.attendees, initiative: e.initiative })), dir, now);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { SENSITIVITY, importEvents, foldEvents, appendImport, appendNote, readLines, cacheMarkdown };
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* calendar-model.js — the pure model behind the Calendar views (console3 wave C2).
|
|
3
|
+
*
|
|
4
|
+
* rangeFor(view, date) → { view, date, start, end, days } ; end is the last day, inclusive
|
|
5
|
+
* bucketEvents(events, view, date) → { range, unit, cells: [{ key, start, end, events[] }] }
|
|
6
|
+
* ganttRows(initiatives, view, date, opts) → rows: planned against forecast against promised, with a status
|
|
7
|
+
* strategyLens(initiatives, krs, today) → { north_star, deviations, pivot, reason }
|
|
8
|
+
*
|
|
9
|
+
* No I/O and no clock: every date comes in as 'YYYY-MM-DD'. Weeks start on Monday; a quarter is a calendar quarter.
|
|
10
|
+
* Nothing here invents a number: a plan, a forecast or a KR metric that was not supplied is reported as missing.
|
|
11
|
+
*
|
|
12
|
+
* SLIP RULE. slip_days = forecast end minus planned end. Tolerance = the initiative's slip_tolerance_days, else 10 percent
|
|
13
|
+
* of the planned duration (rounded, at least 1 day when a duration is known). slip <= 0 is on time; 0 < slip < tolerance is
|
|
14
|
+
* on time; slip = tolerance up to twice tolerance is at risk; slip beyond twice tolerance is delayed. An initiative past
|
|
15
|
+
* its promised date with no end (not done, no actual end) is overdue, which wins over the slip statuses.
|
|
16
|
+
*
|
|
17
|
+
* DEVIATION RULE. A KR's expected value is a straight line from baseline (at start) to target (at date). EARLY_WARNING is
|
|
18
|
+
* the shortfall, as a share of the baseline-to-target span, at which a KR is flagged: expected minus actual >= 10 percent.
|
|
19
|
+
* PIVOT RULE. A KR signals a pivot when it carries at least PIVOT_CHECKS (3) dated checks, each behind the line by the
|
|
20
|
+
* early-warning share, and the latest shortfall is no smaller than the first (it is not closing). */
|
|
21
|
+
|
|
22
|
+
const V = require('./derived/derive.js');
|
|
23
|
+
|
|
24
|
+
/* BACKED BY THE PM'S SAMPLE: the slip tolerance (derived/derive.js). REPO-WRITTEN, the sample has no equivalent: the Day to Year
|
|
25
|
+
* ranges and buckets, the on time / at risk / delayed bands, and the strategy lens (deviation and pivot rules). */
|
|
26
|
+
const DAY = 864e5;
|
|
27
|
+
const EARLY_WARNING = 0.10;
|
|
28
|
+
const PIVOT_CHECKS = 3;
|
|
29
|
+
const VIEWS = ['day', 'week', 'month', 'quarter', 'year'];
|
|
30
|
+
|
|
31
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
32
|
+
const isDate = (s) => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}/.test(s) && !Number.isNaN(Date.parse(s.slice(0, 10) + 'T00:00:00Z'));
|
|
33
|
+
const ms = (s) => Date.parse(String(s).slice(0, 10) + 'T00:00:00Z');
|
|
34
|
+
const fmt = (t) => new Date(t).toISOString().slice(0, 10);
|
|
35
|
+
const addDays = (s, n) => fmt(ms(s) + n * DAY);
|
|
36
|
+
const diffDays = (a, b) => Math.round((ms(a) - ms(b)) / DAY);
|
|
37
|
+
const ymd = (y, m, d) => `${y}-${pad(m)}-${pad(d)}`;
|
|
38
|
+
const lastOfMonth = (y, m) => fmt(Date.UTC(y, m, 0)); // m is 1-based; day 0 of the next month
|
|
39
|
+
|
|
40
|
+
function rangeFor(view, date) {
|
|
41
|
+
if (!VIEWS.includes(view)) throw new Error('unknown view: ' + view);
|
|
42
|
+
if (!isDate(date)) throw new Error('date must be YYYY-MM-DD');
|
|
43
|
+
const d = date.slice(0, 10);
|
|
44
|
+
const y = Number(d.slice(0, 4)); const m = Number(d.slice(5, 7));
|
|
45
|
+
let start; let end;
|
|
46
|
+
if (view === 'day') { start = d; end = d; }
|
|
47
|
+
else if (view === 'week') { const dow = (new Date(ms(d)).getUTCDay() + 6) % 7; start = addDays(d, -dow); end = addDays(start, 6); }
|
|
48
|
+
else if (view === 'month') { start = ymd(y, m, 1); end = lastOfMonth(y, m); }
|
|
49
|
+
else if (view === 'quarter') { const q = Math.floor((m - 1) / 3); start = ymd(y, q * 3 + 1, 1); end = lastOfMonth(y, q * 3 + 3); }
|
|
50
|
+
else { start = ymd(y, 1, 1); end = ymd(y, 12, 31); }
|
|
51
|
+
return { view, date: d, start, end, days: diffDays(end, start) + 1 };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* the cell unit of a view: a day view and a week view hold days, a month holds days, a quarter holds weeks, a year months */
|
|
55
|
+
const UNIT = { day: 'day', week: 'day', month: 'day', quarter: 'week', year: 'month' };
|
|
56
|
+
|
|
57
|
+
function cellsFor(range, unit) {
|
|
58
|
+
const cells = [];
|
|
59
|
+
if (unit === 'day') for (let d = range.start; d <= range.end; d = addDays(d, 1)) cells.push({ key: d, start: d, end: d });
|
|
60
|
+
else if (unit === 'week') {
|
|
61
|
+
for (let s = rangeFor('week', range.start).start; s <= range.end; s = addDays(s, 7)) cells.push({ key: s, start: s, end: addDays(s, 6) });
|
|
62
|
+
} else {
|
|
63
|
+
for (let mo = 1; mo <= 12; mo++) {
|
|
64
|
+
const y = Number(range.start.slice(0, 4));
|
|
65
|
+
const s = ymd(y, mo, 1); const e = lastOfMonth(y, mo);
|
|
66
|
+
if (e >= range.start && s <= range.end) cells.push({ key: s.slice(0, 7), start: s, end: e });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return cells.map((c) => ({ ...c, events: [] }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* An event lands in every cell its span touches: an all-day event (a date with no time) in its day, a multi-day event in
|
|
73
|
+
* each day it covers. Events with no usable `at` are dropped; events outside the range are not placed. */
|
|
74
|
+
function bucketEvents(events, view, date) {
|
|
75
|
+
const range = rangeFor(view, date);
|
|
76
|
+
const unit = UNIT[view];
|
|
77
|
+
const cells = cellsFor(range, unit);
|
|
78
|
+
for (const e of events || []) {
|
|
79
|
+
if (!e || !isDate(e.at)) continue;
|
|
80
|
+
const a = String(e.at).slice(0, 10);
|
|
81
|
+
let b = e.end && isDate(e.end) ? String(e.end).slice(0, 10) : a;
|
|
82
|
+
// an end at exactly 00:00 of a later day is exclusive (the usual all-day convention), so it does not spill into that day
|
|
83
|
+
if (b > a && /T00:00(:00)?/.test(String(e.end)) ) b = addDays(b, -1);
|
|
84
|
+
if (b < a) b = a;
|
|
85
|
+
for (const c of cells) if (a <= c.end && b >= c.start) c.events.push(e);
|
|
86
|
+
}
|
|
87
|
+
return { range, unit, cells };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* The slip tolerance is the sample's (derived/derive.js tolerance, from facts.js): the initiative's own slip_tolerance_days,
|
|
91
|
+
* else 10 percent of the planned duration, at least 1 day. Called with the planned duration as a start and a promise that far
|
|
92
|
+
* apart. Unlike the older code, an explicit 0 falls back to the default (the sample reads the field with ||). */
|
|
93
|
+
function toleranceOf(i, plannedDays) {
|
|
94
|
+
const explicit = i.slip_tolerance_days != null && i.slip_tolerance_days !== '' && Number.isFinite(Number(i.slip_tolerance_days));
|
|
95
|
+
if (!explicit && plannedDays == null) return null;
|
|
96
|
+
return V.tolerance(explicit ? i : {}, '1970-01-01', addDays('1970-01-01', plannedDays || 0));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ganttRows(initiatives, view, date, opts = {}) {
|
|
100
|
+
const range = rangeFor(view, date);
|
|
101
|
+
const today = isDate(opts.today) ? opts.today.slice(0, 10) : range.date;
|
|
102
|
+
const rows = [];
|
|
103
|
+
for (const i of initiatives || []) {
|
|
104
|
+
if (!i || !i.id) continue;
|
|
105
|
+
const promised = isDate(i.promised) ? i.promised.slice(0, 10) : null;
|
|
106
|
+
let pStart = i.planned && isDate(i.planned.start) ? i.planned.start.slice(0, 10) : (isDate(i.planned_start) ? i.planned_start.slice(0, 10) : null);
|
|
107
|
+
let pEnd = i.planned && isDate(i.planned.end) ? i.planned.end.slice(0, 10) : (isDate(i.planned_end) ? i.planned_end.slice(0, 10) : promised);
|
|
108
|
+
const pd = i.plannedDays != null ? Number(i.plannedDays) : (i.planned_days != null ? Number(i.planned_days) : null);
|
|
109
|
+
if (!pStart && pEnd && Number.isFinite(pd)) pStart = addDays(pEnd, -pd);
|
|
110
|
+
const fStart = i.forecast && isDate(i.forecast.start) ? i.forecast.start.slice(0, 10) : null;
|
|
111
|
+
const fEnd = i.forecast && typeof i.forecast === 'object' ? (isDate(i.forecast.end) ? i.forecast.end.slice(0, 10) : null) : (isDate(i.forecast) ? String(i.forecast).slice(0, 10) : null);
|
|
112
|
+
const duration = pStart && pEnd ? Math.max(0, diffDays(pEnd, pStart)) : (Number.isFinite(pd) ? pd : null);
|
|
113
|
+
const tol = toleranceOf(i, duration);
|
|
114
|
+
const slip = pEnd && fEnd ? diffDays(fEnd, pEnd) : 0;
|
|
115
|
+
const ended = !!(i.done || i.actual_end);
|
|
116
|
+
let status;
|
|
117
|
+
if (promised && !ended && promised < today) status = 'overdue';
|
|
118
|
+
else if (!pEnd || !fEnd) status = 'no plan';
|
|
119
|
+
else if (slip <= 0) status = 'on time';
|
|
120
|
+
else if (tol == null || slip < tol) status = 'on time';
|
|
121
|
+
else if (slip <= 2 * tol) status = 'at risk';
|
|
122
|
+
else status = 'delayed';
|
|
123
|
+
// keep a row when its bar touches the range, or when it has no dates at all (so a missing plan is shown, not dropped)
|
|
124
|
+
const lo = pStart || fStart || pEnd || fEnd || promised; const hi = [pEnd, fEnd, promised].filter(Boolean).sort().pop() || lo;
|
|
125
|
+
if (lo && !(lo <= range.end && hi >= range.start)) continue;
|
|
126
|
+
rows.push({
|
|
127
|
+
id: i.id, title: i.title || i.id,
|
|
128
|
+
planned: pEnd || pStart ? { start: pStart, end: pEnd } : null,
|
|
129
|
+
forecast: fEnd || fStart ? { start: fStart || pStart, end: fEnd } : null,
|
|
130
|
+
promised, status, slip_days: slip, tolerance_days: tol,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return rows;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function dayOf(x) { return isDate(x) ? String(x).slice(0, 10) : null; }
|
|
137
|
+
|
|
138
|
+
/* expected value on the straight line from baseline at start to target at date, clamped to the period */
|
|
139
|
+
function expectedAt(kr, at) {
|
|
140
|
+
const start = dayOf(kr.start) || rangeFor('quarter', kr.date).start;
|
|
141
|
+
const span = diffDays(kr.date, start);
|
|
142
|
+
const f = span <= 0 ? 1 : Math.min(1, Math.max(0, diffDays(at, start) / span));
|
|
143
|
+
return kr.baseline + (kr.target - kr.baseline) * f;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function strategyLens(initiatives, krs, today) {
|
|
147
|
+
const inis = (initiatives || []).filter(Boolean);
|
|
148
|
+
const list = Array.isArray(krs) ? krs.filter(Boolean) : [];
|
|
149
|
+
const out = {
|
|
150
|
+
north_star: { progress: null, scored: 0, total: list.length, initiatives: { total: inis.length, done: inis.filter((i) => i.done).length } },
|
|
151
|
+
deviations: [], unscored: [], pivot: { signal: false, reason: '' }, reason: '',
|
|
152
|
+
};
|
|
153
|
+
if (!list.length) {
|
|
154
|
+
out.reason = 'No key results were supplied, so nothing is scored.';
|
|
155
|
+
out.pivot.reason = out.reason;
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
const num = (v) => v !== null && v !== undefined && v !== '' && Number.isFinite(Number(v));
|
|
159
|
+
const progresses = []; const pivotKrs = [];
|
|
160
|
+
for (const kr of list) {
|
|
161
|
+
const name = kr.id || kr.title || kr.kr || 'kr';
|
|
162
|
+
const ok = kr.metric && num(kr.baseline) && num(kr.target) && num(kr.actual) && isDate(kr.date) && Number(kr.target) !== Number(kr.baseline);
|
|
163
|
+
if (!ok) { out.unscored.push({ kr: name, status: 'no metric' }); continue; }
|
|
164
|
+
const k = { ...kr, baseline: Number(kr.baseline), target: Number(kr.target), actual: Number(kr.actual) };
|
|
165
|
+
const at = isDate(today) ? today.slice(0, 10) : k.date;
|
|
166
|
+
const span = k.target - k.baseline;
|
|
167
|
+
const expected = expectedAt(k, at);
|
|
168
|
+
const shortfall = (expected - k.actual) / Math.abs(span) * Math.sign(span); // positive when behind the line
|
|
169
|
+
progresses.push(Math.min(1, Math.max(0, (k.actual - k.baseline) / span)));
|
|
170
|
+
out.deviations.push({ kr: name, metric: k.metric, expected: +expected.toFixed(4), actual: k.actual, shortfall: +shortfall.toFixed(4), early_warning: shortfall >= EARLY_WARNING });
|
|
171
|
+
const checks = (Array.isArray(kr.checks) ? kr.checks : []).filter((c) => c && isDate(c.date) && num(c.actual)).sort((a, b) => ms(a.date) - ms(b.date));
|
|
172
|
+
if (checks.length >= PIVOT_CHECKS) {
|
|
173
|
+
const sf = checks.map((c) => (expectedAt(k, c.date.slice(0, 10)) - Number(c.actual)) / Math.abs(span) * Math.sign(span));
|
|
174
|
+
if (sf.every((v) => v >= EARLY_WARNING) && sf[sf.length - 1] >= sf[0]) pivotKrs.push(name);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
out.north_star.scored = progresses.length;
|
|
178
|
+
out.north_star.progress = progresses.length ? +(progresses.reduce((a, b) => a + b, 0) / progresses.length).toFixed(4) : null;
|
|
179
|
+
if (!progresses.length) out.reason = 'Every key result is missing a metric, so nothing is scored.';
|
|
180
|
+
if (pivotKrs.length) out.pivot = { signal: true, reason: `${pivotKrs.join(', ')} behind the line at ${PIVOT_CHECKS} or more checks and not closing.` };
|
|
181
|
+
else out.pivot.reason = progresses.length ? 'No key result has been behind the line at three checks in a row.' : out.reason;
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = { VIEWS, EARLY_WARNING, PIVOT_CHECKS, rangeFor, bucketEvents, ganttRows, strategyLens, cellsFor };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* card-state.js — the run card's logic, a thin adapter over scripts/reply-contract/reply-contract.js (the PM's sample).
|
|
3
|
+
* The state is the contract's fold phase, never re-derived here; the moves are the contract's nextMoves; a correction
|
|
4
|
+
* is the contract's applyMove record. What is local: card shaping, swipes, fallbacks, the coverage share.
|
|
5
|
+
* cardFor(runLines, { run, ctx, extra, ended, error }) · nextMoves(result) · correctionEvent(kind, reason, run, now)
|
|
6
|
+
* swipeAction(direction, card) · fallbackFor(kind) · coverageShare(runs) · permissionCard · questionCard
|
|
7
|
+
*/
|
|
8
|
+
const C = require('./reply-contract/reply-contract');
|
|
9
|
+
const { foldReplies } = require('./replies');
|
|
10
|
+
|
|
11
|
+
const REJECT_REASONS = C.REJECT.slice();
|
|
12
|
+
const REVISE_OPTIONS = Object.keys(C.REVISE);
|
|
13
|
+
const COVERAGE_LIMIT = 0.1;
|
|
14
|
+
const DENY_MESSAGE = 'waiting for the PM';
|
|
15
|
+
const PM_PRESS = { by: 'pm', via: 'ui' };
|
|
16
|
+
|
|
17
|
+
const TITLES = {
|
|
18
|
+
queued: 'Starting', not_started: 'Not started', working: 'Working', asking: 'Needs your answer', permission: 'Needs your permission',
|
|
19
|
+
attention: 'Needs the terminal', paused: 'Paused', quiet: 'Quiet', degraded: 'Plain text only', failed: 'Failed', done: 'Result',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// The card for one run. `extra` = lines from other files (the PM's answers); `ended`/`error` close the run from outside.
|
|
23
|
+
function cardFor(runLines = [], { run, ctx, extra = [], ended = false, error = false } = {}) {
|
|
24
|
+
let lines = runLines.concat(extra);
|
|
25
|
+
const r = run || require('./replies').runOf(lines);
|
|
26
|
+
if ((ended || error) && !lines.some((l) => l && l.kind === 'end')) lines = lines.concat([{ kind: 'end', run: r.id, session: r.session, exit: error ? 1 : 0 }]);
|
|
27
|
+
const vm = foldReplies(lines, r, ctx);
|
|
28
|
+
const card = {
|
|
29
|
+
state: vm.phase, title: TITLES[vm.phase] || vm.phase, steps: vm.steps, ask: vm.ask, permission: vm.permission, attention: vm.attention,
|
|
30
|
+
failure: vm.failure, recover: vm.recover, problems: vm.problems, unlabelled: vm.unlabelled, fallbackText: vm.fallbackText,
|
|
31
|
+
result: vm.result, claims: (vm.result && vm.result.claims) || [], blocked: vm.failure && vm.failure.code === 'blocked' ? vm.failure.why : null,
|
|
32
|
+
nextMoves: [], fallback: null,
|
|
33
|
+
};
|
|
34
|
+
if (vm.result) { card.nextMoves = nextMoves(vm.result); card.revise = REVISE_OPTIONS; card.reject = REJECT_REASONS; }
|
|
35
|
+
if (vm.recover.includes('open_session')) card.fallback = fallbackFor(vm.phase === 'degraded' ? 'plain' : 'terminal');
|
|
36
|
+
return card;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The moves under a result: { verb, by, label, prompt? }, the contract's own.
|
|
40
|
+
function nextMoves(result, opts) {
|
|
41
|
+
return C.nextMoves(result || {}, opts).moves;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// An append-only correction event. Every Reject and Revise is one; the caller appends it with appendOutbox.
|
|
45
|
+
function correctionEvent(kind, reason, run, now = new Date()) {
|
|
46
|
+
if (kind !== 'reject' && kind !== 'revise') throw new Error('a correction is a reject or a revise');
|
|
47
|
+
const m = C.applyMove(kind, reason, { id: run || null }, PM_PRESS);
|
|
48
|
+
if (m.error) throw new Error(m.error);
|
|
49
|
+
return { type: 'correction', kind, reason, run: run || null, counted: true, at: new Date(now).toISOString(), record: m.record };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Right = the card's primary action; left = open the reply tray. Neither reaches the outbox.
|
|
53
|
+
function swipeAction(direction, card = {}) {
|
|
54
|
+
if (direction === 'left') return { action: 'open-reply-tray', outward: false };
|
|
55
|
+
if (direction === 'right') return { action: card.state === 'done' && (card.nextMoves || []).some((m) => m.verb === 'accept') ? 'accept' : 'none', outward: false };
|
|
56
|
+
return { action: 'none', outward: false };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function fallbackFor(kind) {
|
|
60
|
+
if (kind === 'plain' || kind === 'tier2' || kind === 2) {
|
|
61
|
+
return { tier: 2, shown: 'plain text', checked: false, canBackClaim: false, note: 'The agent did not use the format, so nothing here is checked.' };
|
|
62
|
+
}
|
|
63
|
+
return { tier: 3, shown: 'terminal only', checked: false, canBackClaim: false, action: 'Open the session' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// runs: [{ tier: 1|2|3 }]. The PRD: above about 10% the contract is wrong. The number is returned, never hidden.
|
|
67
|
+
function coverageShare(runs = []) {
|
|
68
|
+
const n = runs.length;
|
|
69
|
+
const low = runs.filter((r) => r && (r.tier === 2 || r.tier === 3)).length;
|
|
70
|
+
const share = n ? low / n : 0;
|
|
71
|
+
return { share, low, runs: n, overLimit: share > COVERAGE_LIMIT, limit: COVERAGE_LIMIT };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function permissionCard(request = {}) {
|
|
75
|
+
return {
|
|
76
|
+
kind: 'permission', command: request.command || '', cwd: request.cwd || '', why: request.why || '',
|
|
77
|
+
options: ['allow once', 'allow always for this branch', 'deny'],
|
|
78
|
+
onDeny: { behavior: 'deny', message: DENY_MESSAGE }, denyMessage: DENY_MESSAGE,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The CLI question tool's input, through the contract's askFromTool (up to 4 questions, 6 options each).
|
|
83
|
+
function questionCard(input = {}) {
|
|
84
|
+
const qs = Array.isArray(input) ? input : input.questions || [];
|
|
85
|
+
const r = C.askFromTool({ questions: qs.map((q) => ({ ...q, options: (q.options || []).map((o) => (typeof o === 'string' ? { label: o } : o)) })) });
|
|
86
|
+
return { kind: 'question', questions: r.asks || [], truncated: qs.length > 4, error: r.error || null, onDeny: { behavior: 'deny', message: DENY_MESSAGE } };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
REJECT_REASONS, REVISE_OPTIONS, COVERAGE_LIMIT, DENY_MESSAGE, TITLES,
|
|
91
|
+
cardFor, nextMoves, correctionEvent, swipeAction, fallbackFor, coverageShare, permissionCard, questionCard,
|
|
92
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* derive.js — the PM's derived-view expressions from sample/reader.js and sample/facts.js, lifted into pure functions.
|
|
3
|
+
* reader.js is a script that runs on load against one portfolio root; the repo modules need the same expressions over data
|
|
4
|
+
* they are given. Every expression here is copied from reader.js / facts.js (the section number is named at each one) and
|
|
5
|
+
* only parameterised. derived.test.js runs both on the generated sample portfolio and asserts they agree, so the two
|
|
6
|
+
* copies cannot drift. When the sample changes, change reader.js/facts.js first, then this file.
|
|
7
|
+
*/
|
|
8
|
+
const P = require('./plan');
|
|
9
|
+
const { fold, plan, day, fmt, D } = P;
|
|
10
|
+
const days = (a, b) => Math.round((a - b) / D);
|
|
11
|
+
|
|
12
|
+
/* reader.js section 1: one row per check (snap): forecast date, slip against the promise, scope points. */
|
|
13
|
+
const pts = (s) => Object.values(s).reduce((a, t) => a + t.pts, 0);
|
|
14
|
+
function timeline(events, start, promised) {
|
|
15
|
+
const snaps = [...new Set(events.map((e) => e.snap))].sort();
|
|
16
|
+
return snaps.map((c) => { const s = fold(events, c), p = plan(s, start); return { check: c, forecast: fmt(p.forecast), slip: days(p.forecast, day(promised)), points: pts(s) }; });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* facts.js: the slip tolerance is the initiative's own field, else 10 percent of the planned duration (at least 1 day). */
|
|
20
|
+
const tolerance = (init, start, promised) => +init.slip_tolerance_days || Math.max(1, Math.round(0.1 * Math.round((day(promised) - day(start)) / D)));
|
|
21
|
+
|
|
22
|
+
/* facts.js: critical path = the tickets with slack 0 against the forecast. */
|
|
23
|
+
function critical(state, fc) {
|
|
24
|
+
const succ = {};
|
|
25
|
+
Object.entries(state).forEach(([k, t]) => t.deps.forEach((d) => (succ[d] = succ[d] || []).push(k)));
|
|
26
|
+
const tail = (k) => Math.max(0, ...(succ[k] || []).map((s) => state[s].dur + tail(s)));
|
|
27
|
+
return new Set(Object.keys(state).filter((k) => fc.end[k] + tail(k) * D === fc.forecast));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* facts.js ablate(): remove one event, recompute; the difference is what that event cost. */
|
|
31
|
+
function attribution(events, start, firstSnap) {
|
|
32
|
+
const fc = plan(fold(events), start);
|
|
33
|
+
const out = [];
|
|
34
|
+
for (const e of events) {
|
|
35
|
+
if (e.snap === firstSnap) continue;
|
|
36
|
+
try { out.push({ ev: e, days: Math.round((fc.forecast - plan(fold(events.filter((x) => x !== e)), start).forecast) / D) }); } catch { out.push({ ev: e, days: null }); }
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* reader.js section 3: document graph edges. docs: { id: { type, rev, sources: [{id, rev}], ... } }; findings: open findings. */
|
|
42
|
+
const edgeState = (docs, src, seen) => { const u = docs[src]; if (!u) return 'missing'; if (u.rev <= seen) return 'in sync'; return ['Decision', 'Benchmark'].includes(u.type) ? 'conflict' : 'out of sync'; };
|
|
43
|
+
function docGraph(docs, findings) {
|
|
44
|
+
const edges = [];
|
|
45
|
+
for (const [id, d] of Object.entries(docs)) for (const s of d.sources || []) edges.push({ from: s.id, to: id, state: edgeState(docs, s.id, s.rev) });
|
|
46
|
+
for (const f of (findings || []).filter((x) => x.status === 'open')) {
|
|
47
|
+
const e = edges.find((x) => x.from === f.vs && x.to === f.doc);
|
|
48
|
+
if (e) { e.state = 'conflict'; e.reason = 'finding'; } else edges.push({ from: f.vs, to: f.doc, state: 'conflict', reason: 'finding: ' + f.quote });
|
|
49
|
+
}
|
|
50
|
+
return edges;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* reader.js section 4: the six health checks for one document. need: { <type>: [required headings] } (doc-types.json
|
|
54
|
+
* `required`); readers: doc-readers records { doc, who, rev }; now: 'YYYY-MM-DD'. Doc bodies are in docs[id]._body. */
|
|
55
|
+
function docHealth(id, docs, edges, readers, need, now) {
|
|
56
|
+
const d = docs[id], my = edges.filter((e) => e.to === id), kind = (i) => (docs[i] || {}).type, chk = {};
|
|
57
|
+
chk.fresh = !d.stale_after || d.stale_after >= now;
|
|
58
|
+
chk.decisions = !my.some((e) => kind(e.from) === 'Decision' && e.state !== 'in sync');
|
|
59
|
+
chk.data = !my.some((e) => kind(e.from) === 'Benchmark' && e.state !== 'in sync') && chk.fresh;
|
|
60
|
+
chk.complete = (need[d.type] || []).every((h) => (d._body || '').includes('## ' + h));
|
|
61
|
+
const reachKR = (i, seen = new Set()) => { if (seen.has(i) || !docs[i]) return false; seen.add(i); if (docs[i].type === 'KeyResult') return true; return (docs[i].sources || []).some((x) => reachKR(x.id, seen)); };
|
|
62
|
+
const citedBy = Object.entries(docs).filter(([, x]) => (x.sources || []).some((y) => y.id === id)).map(([k]) => k);
|
|
63
|
+
chk.traced = (['Research', 'Benchmark'].includes(d.type) ? citedBy.some((k) => reachKR(k)) : reachKR(id)) && (d.type !== 'PRD' || (d.tickets || []).length > 0);
|
|
64
|
+
const lastR = {}; (readers || []).filter((r) => r.doc === id).forEach((r) => (lastR[r.who] = r.rev));
|
|
65
|
+
chk.readers = Object.values(lastR).every((r) => r >= d.rev);
|
|
66
|
+
return chk;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* reader.js section 7: what each person was told against what is true now. messages: sent.jsonl records;
|
|
70
|
+
* pl: plan(state,start) of the current state. Subjects: 'ship-date' and 'ticket:<id>'. */
|
|
71
|
+
const truthOf = (subj, pl) => (subj === 'ship-date' ? fmt(pl.forecast) : subj.startsWith('ticket:') && pl.end[subj.slice(7)] !== undefined ? fmt(pl.end[subj.slice(7)]) : null);
|
|
72
|
+
function heldVsTrue(messages, pl) {
|
|
73
|
+
const rows = [];
|
|
74
|
+
for (const m of messages) for (const c of m.claims) {
|
|
75
|
+
const t = truthOf(c.subject, pl), gap = t == null ? null : days(day(t), day(c.value));
|
|
76
|
+
rows.push({ to: m.to, subject: c.subject, told: c.value, true: t, gap, state: gap === null ? 'unknown' : gap ? gap + 'd apart' : 'in sync' });
|
|
77
|
+
}
|
|
78
|
+
return rows;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* gen.js shared/trust/claim-rules.json: the evidence labels and the per-destination strictness trust.checkReport reads. */
|
|
82
|
+
const CLAIM_RULES = {
|
|
83
|
+
basis: {
|
|
84
|
+
fact: { needs: ['source'] }, cited: { needs: ['source'] }, reported: { needs: ['by', 'at'] }, estimate: { needs: ['method', 'range'] },
|
|
85
|
+
assumption: { needs: ['owner', 'falsifier', 'review_by'] }, judgement: { needs: ['by'] } },
|
|
86
|
+
destinations: {
|
|
87
|
+
leadership: { allow: ['fact', 'cited', 'reported', 'estimate', 'assumption', 'judgement'], unlabelled: 'block', incomplete: 'block', stale: 'block', overdue_assumption: 'block', label_visible: true },
|
|
88
|
+
customer: { allow: ['fact', 'cited'], unlabelled: 'block', incomplete: 'block', stale: 'block', overdue_assumption: 'block', label_visible: false },
|
|
89
|
+
'internal-draft': { allow: ['fact', 'cited', 'reported', 'estimate', 'assumption', 'judgement'], unlabelled: 'warn', incomplete: 'warn', stale: 'warn', overdue_assumption: 'warn', label_visible: true } },
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
module.exports = { timeline, tolerance, critical, attribution, docGraph, docHealth, truthOf, heldVsTrue, CLAIM_RULES, pts };
|