create-pmos 0.52.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/mods/pmos-cockpit/README.md +13 -0
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
- package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/plan-items-stage.js +271 -0
- package/payload/scripts/pmos-log.sh +18 -7
- package/payload/scripts/workflow-state.js +28 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/bin/pmos.js +15 -1
- package/workspace/cut-stub.js +40 -0
- package/workspace/first-paint-audit.js +145 -0
- package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
- package/workspace/fixtures/initiatives-api/list.json +176 -0
- package/workspace/fixtures/initiatives-api/post-403.json +5 -0
- package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
- package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
- package/workspace/fixtures/initiatives-api/sheet.json +112 -0
- package/workspace/fixtures/report-api/draft.json +177 -0
- package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
- package/workspace/fixtures/today-api/provider-rows.json +204 -0
- package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
- package/workspace/guide/board.md +81 -0
- package/workspace/guide/breakdown.md +154 -0
- package/workspace/guide/command-palette.md +75 -0
- package/workspace/guide/index.md +27 -17
- package/workspace/guide/initiatives-roadmap.md +130 -0
- package/workspace/guide/initiatives-screen.md +89 -0
- package/workspace/guide/initiatives-timeline.md +57 -0
- package/workspace/guide/integrated-work.md +94 -0
- package/workspace/guide/knowledge-graph.md +3 -68
- package/workspace/guide/layout.md +98 -32
- package/workspace/guide/live-notes.md +3 -3
- package/workspace/guide/pm-only-acts.md +89 -0
- package/workspace/guide/pm-only-lines.md +61 -0
- package/workspace/guide/reports-page.md +80 -0
- package/workspace/guide/reports.md +73 -0
- package/workspace/guide/timeline.md +70 -0
- package/workspace/guide/today.md +85 -0
- package/workspace/guide/validate-with-typesafe.md +1 -1
- package/workspace/lib/actor.js +91 -0
- package/workspace/lib/board-cache.js +32 -2
- package/workspace/lib/board-cli.js +108 -0
- package/workspace/lib/board-fold.js +232 -0
- package/workspace/lib/breakdown-export.js +80 -0
- package/workspace/lib/capture.js +98 -0
- package/workspace/lib/engine/plan-items-stage.js +271 -0
- package/workspace/lib/engine/workflow-state.js +28 -2
- package/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/grant-mint.js +113 -0
- package/workspace/lib/health-rules-factory.js +92 -0
- package/workspace/lib/health-rules-plan.js +164 -0
- package/workspace/lib/health.js +79 -0
- package/workspace/lib/initiative-cli.js +222 -0
- package/workspace/lib/initiative-display.js +183 -0
- package/workspace/lib/initiative-needs.js +127 -0
- package/workspace/lib/initiative-rows.js +206 -0
- package/workspace/lib/initiative-writer.js +141 -0
- package/workspace/lib/issues.js +206 -0
- package/workspace/lib/item-cli.js +195 -0
- package/workspace/lib/item-spans.js +137 -0
- package/workspace/lib/measure-cli.js +100 -0
- package/workspace/lib/measurements.js +189 -0
- package/workspace/lib/mobile-nav.js +57 -53
- package/workspace/lib/needs-providers.js +120 -0
- package/workspace/lib/placements.js +440 -0
- package/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/plan-ledgers.js +85 -0
- package/workspace/lib/plan-rules.js +72 -0
- package/workspace/lib/plan-tree.js +367 -0
- package/workspace/lib/pm-guard.js +174 -0
- package/workspace/lib/pm-lines-needs.js +331 -0
- package/workspace/lib/pmos-verbs.js +66 -0
- package/workspace/lib/proactive.js +7 -4
- package/workspace/lib/report-cli.js +152 -0
- package/workspace/lib/report-draft.js +579 -0
- package/workspace/lib/report-needs.js +213 -0
- package/workspace/lib/route-modules.js +128 -0
- package/workspace/lib/routes-board.js +91 -0
- package/workspace/lib/routes-capture.js +52 -0
- package/workspace/lib/routes-initiatives.js +371 -0
- package/workspace/lib/routes-reports.js +163 -0
- package/workspace/lib/routes-timeline.js +197 -0
- package/workspace/lib/routes-today.js +32 -0
- package/workspace/lib/today-cli.js +99 -0
- package/workspace/lib/today.js +333 -0
- package/workspace/lib/triage.js +163 -0
- package/workspace/lib/verify-work-cli.js +81 -0
- package/workspace/lib/work-check-needs.js +99 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +4 -5
- package/workspace/public/index.html +29 -19
- package/workspace/public/pm/app.js +299 -30
- package/workspace/public/pm/bd-board.js +113 -0
- package/workspace/public/pm/bd-drag.js +59 -0
- package/workspace/public/pm/bd-model.js +152 -0
- package/workspace/public/pm/bd-timeline.js +233 -0
- package/workspace/public/pm/bd-tree.js +244 -0
- package/workspace/public/pm/board-copy.js +57 -0
- package/workspace/public/pm/breakdown-api.js +58 -0
- package/workspace/public/pm/breakdown-copy.js +138 -0
- package/workspace/public/pm/components.css +3054 -0
- package/workspace/public/pm/cycle-review.js +125 -0
- package/workspace/public/pm/data.js +40 -187
- package/workspace/public/pm/dest-board.js +438 -0
- package/workspace/public/pm/dest-initiatives.js +134 -0
- package/workspace/public/pm/dest-reports.js +270 -0
- package/workspace/public/pm/dest-settings.js +217 -0
- package/workspace/public/pm/dest-today.js +176 -0
- package/workspace/public/pm/dialogs.js +88 -0
- package/workspace/public/pm/display-word.js +56 -0
- package/workspace/public/pm/dock.js +105 -0
- package/workspace/public/pm/fixture.js +8 -227
- package/workspace/public/pm/frame-copy.js +94 -0
- package/workspace/public/pm/handoff-box.js +82 -0
- package/workspace/public/pm/http.js +88 -0
- package/workspace/public/pm/icons.js +53 -0
- package/workspace/public/pm/initiative-list.js +218 -0
- package/workspace/public/pm/initiative-sheet.js +261 -0
- package/workspace/public/pm/initiatives-api.js +59 -0
- package/workspace/public/pm/initiatives-copy.js +215 -0
- package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
- package/workspace/public/pm/initiatives-timeline.js +410 -0
- package/workspace/public/pm/initiatives-ui.js +123 -0
- package/workspace/public/pm/item-sheet.js +251 -0
- package/workspace/public/pm/keep-card.js +204 -0
- package/workspace/public/pm/lifecycle.js +7 -1
- package/workspace/public/pm/mobile.css +49 -20
- package/workspace/public/pm/mobile.js +157 -29
- package/workspace/public/pm/modes-live.js +7 -883
- package/workspace/public/pm/modes.js +32 -1024
- package/workspace/public/pm/new-initiative-sheet.js +74 -0
- package/workspace/public/pm/next-step-ui.js +3 -2
- package/workspace/public/pm/palette-copy.js +51 -0
- package/workspace/public/pm/palette.js +312 -0
- package/workspace/public/pm/panel-breakdown.js +702 -0
- package/workspace/public/pm/panel-cockpit.js +63 -295
- package/workspace/public/pm/panel-detail.js +25 -172
- package/workspace/public/pm/panel-initiatives.js +1 -19
- package/workspace/public/pm/panel-repos.js +31 -81
- package/workspace/public/pm/panel-tabs.js +197 -110
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/portfolio-report.js +61 -0
- package/workspace/public/pm/primitives.js +656 -0
- package/workspace/public/pm/print.css +33 -0
- package/workspace/public/pm/provider-cards.js +136 -0
- package/workspace/public/pm/registry.js +194 -0
- package/workspace/public/pm/release-card.js +110 -0
- package/workspace/public/pm/report-view.js +309 -0
- package/workspace/public/pm/reports-copy.js +114 -0
- package/workspace/public/pm/roadmap.js +271 -0
- package/workspace/public/pm/sig-watch.js +41 -0
- package/workspace/public/pm/state.js +8 -4
- package/workspace/public/pm/status-banner.js +13 -5
- package/workspace/public/pm/tab-doc.js +2 -4
- package/workspace/public/pm/tab-term.js +12 -2
- package/workspace/public/pm/tab-web.js +3 -2
- package/workspace/public/pm/timeline-copy.js +48 -0
- package/workspace/public/pm/today-cards.js +244 -0
- package/workspace/public/pm/today-copy.js +97 -0
- package/workspace/public/pm/tokens.css +16 -18
- package/workspace/public/pm/topbar.js +102 -0
- package/workspace/public/pm/triage-card.js +60 -0
- package/workspace/public/pm/undo-hold.js +103 -0
- package/workspace/public/pm/view-addrepo.js +2 -1
- package/workspace/public/pm/view-livenotes.js +3 -1
- package/workspace/public/pm/view-today.js +7 -162
- package/workspace/public/vendor/Sortable.min.js +2 -0
- package/workspace/public/vendor/lucide-sprite.js +79 -0
- package/workspace/replaced-tests.json +2864 -0
- package/workspace/run-tests.js +56 -5
- package/workspace/server.js +312 -36
- package/workspace/sheet-answer.js +61 -0
- package/workspace/skills/measure.skill +41 -0
- package/workspace/skills/report-biweekly.skill +23 -25
- package/workspace/skills/tracker-refresh.skill +50 -0
- package/workspace/test-files.json +19 -3
- package/workspace/test-lists/base.json +6 -0
- package/workspace/test-lists/run2-a.json +6 -0
- package/workspace/test-lists/run2-b.json +13 -0
- package/workspace/test-lists/run2-c.json +19 -0
- package/workspace/test-lists/run2-d.json +22 -0
- package/workspace/test-lists/run3-0.json +5 -0
- package/workspace/test-lists/run3-a.json +5 -0
- package/workspace/test-lists/run3-b.json +6 -0
- package/workspace/test-lists/run3-c.json +15 -0
- package/workspace/test-lists/run3-d.json +6 -0
- package/workspace/test-lists/run4-a.json +8 -0
- package/workspace/test-lists/run4-b.json +6 -0
- package/workspace/test-lists/run4-c.json +13 -0
- package/workspace/test-lists/run4-d.json +9 -0
- package/workspace/test-lists/run5-a.json +6 -0
- package/workspace/test-lists/run5-b.json +6 -0
- package/workspace/test-lists/run5-c.json +15 -0
- package/workspace/test-run.js +27 -1
- package/workspace/third-party/README.md +44 -0
- package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
- package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
- package/workspace/design-parity.js +0 -673
- package/workspace/public/pm/tab-task.js +0 -86
- package/workspace/public/pm/view-guide.js +0 -141
- package/workspace/public/pm/view-knowledge.js +0 -225
- package/workspace/public/pm/view-memory.js +0 -122
- package/workspace/public/pm/view-portfolio.js +0 -153
- package/workspace/public/pm/view-reports.js +0 -190
- package/workspace/public/pm/view-scheduled.js +0 -161
- package/workspace/public/pm/view-skills.js +0 -216
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* report-draft.js — THE ONE DRAFTER of a cadence report: the per-initiative blocks since the last issue, the markdown,
|
|
3
|
+
* the PM's notes, and the draft file (pm-os-absorb Run 5, PRD R22, R24; D86 point 3 as ruled on 2026-10-05:
|
|
4
|
+
* one block per initiative active in the window, covering everything since the last issue).
|
|
5
|
+
*
|
|
6
|
+
* draftOf(root, { cadence, slot, now, health }) → the fixtures/report-api/draft.json shape | { ok: false, status, error }
|
|
7
|
+
* render(draft, { notes }) → markdown; each block carries its note markers (below)
|
|
8
|
+
* writeDraft(root, draft, { actor, notes }) → { ok: true, path, sha } | { ok: false, status, error }
|
|
9
|
+
* readNotes(markdown) → { [initiativeId]: text } (frozen here, not a stub)
|
|
10
|
+
* noteBlock(id, text) → the marker pair with the text between (frozen here)
|
|
11
|
+
*
|
|
12
|
+
* WHO CALLS WHAT:
|
|
13
|
+
* - 5A fills draftOf, render and writeDraft, and `pmos report draft <cadence> [<slot>]` calls them.
|
|
14
|
+
* - 5C's routes call them: GET /api/report/draft → draftOf; POST /api/report/draft (Draft now, Redraft) →
|
|
15
|
+
* writeDraft(root, draftOf(…), { actor: <requirePagePress actor>, notes: readNotes(<the file now>) });
|
|
16
|
+
* POST /api/report/note → readNotes, set one id, writeDraft. ONLY writeDraft writes the file.
|
|
17
|
+
* - 5B's skill (report-biweekly.skill) runs `pmos report draft biweekly` and writes prose ONLY between note markers.
|
|
18
|
+
* It never appends a "## Later run HH:MM" section: a Redraft rewrites the file and keeps every note.
|
|
19
|
+
*
|
|
20
|
+
* THE FILE: planning/reports/<cadence>/<slot>.md, where <cadence> is the skill id suffix (`report-biweekly` →
|
|
21
|
+
* `biweekly`, the same spelling as the issues line). A slot that isIssued (lib/issues.js) is frozen: writeDraft
|
|
22
|
+
* refuses it with 409.
|
|
23
|
+
*
|
|
24
|
+
* THE NOTE MARKERS (frozen):
|
|
25
|
+
* <!-- pmos:note <initiative-id> -->
|
|
26
|
+
* …the PM's or the skill's prose, any lines…
|
|
27
|
+
* <!-- /pmos:note -->
|
|
28
|
+
* One pair per block; an id appears at most once; text is kept byte-for-byte across a Redraft (trimmed of the one
|
|
29
|
+
* newline after the open marker and before the close marker).
|
|
30
|
+
*
|
|
31
|
+
* WHAT draftOf FOLDS (Run 5, slice 5A). Local files only, no git fetch and no model (X18):
|
|
32
|
+
* - the window: since the last issue of this cadence before the slot (lib/issues.js), else report_cycle_days back
|
|
33
|
+
* from `now` (X20). Two calls with the same inputs and `now` give the same draft; another `now`, another window;
|
|
34
|
+
* - one block per work-lane initiative active in the window (scripts/lane-defs.js group `work`, D86 point 4): in Now,
|
|
35
|
+
* or with an item line, a horizon move, a gate or a lifecycle line in the window. Off track and at risk first, then
|
|
36
|
+
* the Roadmap order. Stage from initiative-display.js displayOf; health from `health` (a function of the health
|
|
37
|
+
* ctx, or a map by id; tests inject it) else lib/health.js healthOf. An off-track or at-risk block with no reason
|
|
38
|
+
* at its level refuses the whole draft (422);
|
|
39
|
+
* - progress from plan-items (proposed items never count; an item the factory join marks integrated counts done);
|
|
40
|
+
* a measured value only from a measurements line whose source is `<path>@<sha>` (no KR is computed here);
|
|
41
|
+
* - the Timeline strip from item-spans.js, the PM's own acts under Decided this cycle (a pending proposal never),
|
|
42
|
+
* Needs your call from the asks note, for the definition's audience only (X23), and the sources.
|
|
43
|
+
* The definition `<state>/reports/definitions/<cadence>.md` gives `audience` and `sections` (readDefinition). Besides
|
|
44
|
+
* the block notes, two notes belong to the report: `report.headline` (the agent's headline) and `report.asks`. A note
|
|
45
|
+
* whose initiative left the window is kept under "Notes kept from an earlier draft", so no note is ever dropped.
|
|
46
|
+
*/
|
|
47
|
+
const fs = require('fs');
|
|
48
|
+
const path = require('path');
|
|
49
|
+
const crypto = require('crypto');
|
|
50
|
+
const { execFileSync } = require('child_process');
|
|
51
|
+
const ledgers = require('./plan-ledgers.js');
|
|
52
|
+
const consoleState = require('./console-state');
|
|
53
|
+
const planItems = require('./plan-items.js');
|
|
54
|
+
const planTree = require('./plan-tree.js');
|
|
55
|
+
const placements = require('./placements.js');
|
|
56
|
+
const measurements = require('./measurements.js');
|
|
57
|
+
const issues = require('./issues.js');
|
|
58
|
+
const factoryJoin = require('./factory-join.js');
|
|
59
|
+
const healthLib = require('./health.js');
|
|
60
|
+
const display = require('./initiative-display.js');
|
|
61
|
+
const itemSpans = require('./item-spans.js');
|
|
62
|
+
const actorLib = require('./actor.js');
|
|
63
|
+
const reviewGate = require('./review-gate.js');
|
|
64
|
+
const { readRules } = require('./plan-rules.js');
|
|
65
|
+
|
|
66
|
+
const NOTE_OPEN = (id) => `<!-- pmos:note ${id} -->`;
|
|
67
|
+
const NOTE_CLOSE = '<!-- /pmos:note -->';
|
|
68
|
+
const NOTE_RE = /<!-- pmos:note ([a-z0-9][a-z0-9._-]*) -->\n?([\s\S]*?)\n?<!-- \/pmos:note -->/g;
|
|
69
|
+
/** The ids readNotes reads back. A writer refuses any other id, and any text that holds a marker (writeDraft). */
|
|
70
|
+
const NOTE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
71
|
+
const MARKER_RE = /<!-- \/?pmos:note[ >]/;
|
|
72
|
+
|
|
73
|
+
function readNotes(markdown) {
|
|
74
|
+
const out = {};
|
|
75
|
+
if (typeof markdown !== 'string') return out;
|
|
76
|
+
for (const m of markdown.matchAll(NOTE_RE)) if (!Object.prototype.hasOwnProperty.call(out, m[1])) out[m[1]] = m[2];
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function noteBlock(id, text) {
|
|
81
|
+
return `${NOTE_OPEN(id)}\n${text == null ? '' : String(text)}\n${NOTE_CLOSE}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── constants and small helpers ──────────────────────────────────────────────────────────────────────
|
|
85
|
+
/** The two notes that belong to the report, not to an initiative: the agent's headline and its asks to the audience. */
|
|
86
|
+
const HEADLINE_NOTE = 'report.headline';
|
|
87
|
+
const ASKS_NOTE = 'report.asks';
|
|
88
|
+
/** The frame, in its frozen order (D78 as amended by D86 point 3). A definition's `sections` must name these. */
|
|
89
|
+
const FRAME = Object.freeze(['headline', 'timeline', 'initiatives', 'new', 'decided', 'needs', 'sources']);
|
|
90
|
+
const CADENCE_RE = /^[a-z][a-z0-9-]{0,39}$/;
|
|
91
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
92
|
+
const SOURCE_RE = /^[^@\s]+@[0-9a-f]{7,40}$/;
|
|
93
|
+
const DAY = 86400000;
|
|
94
|
+
const SEVERITY = { off: 3, 'at-risk': 2, unknown: 1, on: 0 };
|
|
95
|
+
const LEVEL_WORD = { off: 'Off track', 'at-risk': 'At risk', unknown: 'Unknown', on: 'On track' };
|
|
96
|
+
const HORIZON_WORD = { now: 'Now', next: 'Next', later: 'Later' };
|
|
97
|
+
const HORIZONS = ['now', 'next', 'later'];
|
|
98
|
+
const LIFECYCLE_WORD = { 'on-hold': 'put on hold', held: 'put on hold', closed: 'closed', cancelled: 'cancelled', active: 'reopened' };
|
|
99
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
100
|
+
|
|
101
|
+
const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
|
|
102
|
+
const has = (o, k) => !!o && Object.prototype.hasOwnProperty.call(o, k);
|
|
103
|
+
const isStr = (v) => typeof v === 'string';
|
|
104
|
+
const refuse = (status, error) => ({ ok: false, status, error, index: null });
|
|
105
|
+
const isDate = (s) => isStr(s) && DATE_RE.test(s) && new Date(s + 'T00:00:00Z').toISOString().slice(0, 10) === s;
|
|
106
|
+
const toMs = (v) => (typeof v === 'number' ? v : Date.parse(String(v == null ? '' : v)));
|
|
107
|
+
const iso = (t) => new Date(t).toISOString().replace('.000Z', 'Z');
|
|
108
|
+
const dayOf = (t) => new Date(t).toISOString().slice(0, 10);
|
|
109
|
+
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
|
110
|
+
const byText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
111
|
+
/** 16 Oct, from a date or a time. */
|
|
112
|
+
const short = (v) => { const t = toMs(isStr(v) && DATE_RE.test(v) ? v + 'T00:00:00Z' : v); if (!Number.isFinite(t)) return String(v); const d = new Date(t); return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]}`; };
|
|
113
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
114
|
+
|
|
115
|
+
function engine() {
|
|
116
|
+
const loaded = consoleState.loadEngine();
|
|
117
|
+
if (!loaded.ok) throw new Error(loaded.error);
|
|
118
|
+
return loaded.engine;
|
|
119
|
+
}
|
|
120
|
+
/** `pmos/state` in a kit repository, `planning` in PMOS-self: the `<state>` the report skill names. */
|
|
121
|
+
function stateRel(root) { return engine().layout(path.resolve(String(root))).kit ? 'pmos/state' : 'planning'; }
|
|
122
|
+
/** The draft file of a slot, repository-relative. */
|
|
123
|
+
function draftPath(root, cadence, slot) { return `${stateRel(root)}/reports/${cadence}/${slot}.md`; }
|
|
124
|
+
function definitionPath(root, cadence) { return `${stateRel(root)}/reports/definitions/${cadence}.md`; }
|
|
125
|
+
|
|
126
|
+
/** Every JSON object line of a file; anything else is skipped. A missing file reads empty. Nothing is cached (D33). */
|
|
127
|
+
function readJsonl(file) {
|
|
128
|
+
let text;
|
|
129
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return { rows: [], exists: false, bad: 0 }; }
|
|
130
|
+
const rows = []; let bad = 0;
|
|
131
|
+
for (const raw of text.split('\n')) {
|
|
132
|
+
if (!raw.trim()) continue;
|
|
133
|
+
try { const r = JSON.parse(raw); if (r && typeof r === 'object' && !Array.isArray(r)) rows.push(r); else bad++; } catch { bad++; }
|
|
134
|
+
}
|
|
135
|
+
return { rows, exists: true, bad };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── the definition: structured frontmatter for the script, prose for the agent ─────────────────────────
|
|
139
|
+
/**
|
|
140
|
+
* `<state>/reports/definitions/<cadence>.md`. The script reads `cadence`, `audience` and `sections` from its
|
|
141
|
+
* frontmatter; the body is for the agent (report-biweekly.skill). No file is not an error: the frame is the default
|
|
142
|
+
* and Needs your call stays empty, because no audience is named (X23).
|
|
143
|
+
*/
|
|
144
|
+
function readDefinition(root, cadence) {
|
|
145
|
+
const rel = definitionPath(root, cadence);
|
|
146
|
+
const out = { path: rel, exists: false, cadence: null, audience: null, sections: FRAME.slice(), errors: [] };
|
|
147
|
+
let text;
|
|
148
|
+
try { text = fs.readFileSync(path.join(path.resolve(String(root)), rel), 'utf8'); } catch { return out; }
|
|
149
|
+
out.exists = true;
|
|
150
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
|
|
151
|
+
if (!fm) { out.errors.push('it has no frontmatter'); return out; }
|
|
152
|
+
const map = {};
|
|
153
|
+
let listKey = null;
|
|
154
|
+
for (const raw of fm[1].split(/\r?\n/)) {
|
|
155
|
+
const item = /^\s+-\s+(.*)$/.exec(raw) || /^-\s+(.*)$/.exec(raw);
|
|
156
|
+
if (item && listKey) { map[listKey].push(unquote(item[1])); continue; }
|
|
157
|
+
const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(raw);
|
|
158
|
+
if (!kv) continue;
|
|
159
|
+
const [, k, v] = kv;
|
|
160
|
+
if (v === '') { map[k] = []; listKey = k; continue; }
|
|
161
|
+
listKey = null;
|
|
162
|
+
map[k] = /^\[.*\]$/.test(v.trim()) ? v.trim().slice(1, -1).split(',').map((x) => unquote(x)).filter(Boolean) : unquote(v);
|
|
163
|
+
}
|
|
164
|
+
out.cadence = isStr(map.cadence) ? map.cadence : null;
|
|
165
|
+
if (out.cadence !== cadence) out.errors.push(`its cadence is ${JSON.stringify(out.cadence)}, not ${cadence}`);
|
|
166
|
+
out.audience = isStr(map.audience) && map.audience.trim() ? map.audience.trim() : null;
|
|
167
|
+
if (!out.audience) out.errors.push('it names no audience, so Needs your call stays empty (X23)');
|
|
168
|
+
if (Array.isArray(map.sections)) {
|
|
169
|
+
const got = map.sections.map((s) => String(s).trim());
|
|
170
|
+
if (got.join(',') !== FRAME.join(',')) out.errors.push(`its sections must be ${FRAME.join(', ')}, in that order; the script writes that frame`);
|
|
171
|
+
out.sections = FRAME.slice();
|
|
172
|
+
} else out.errors.push('it lists no sections; the script writes the default frame');
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
function unquote(s) {
|
|
176
|
+
const t = String(s).trim();
|
|
177
|
+
return (/^"(.*)"$/.exec(t) || /^'(.*)'$/.exec(t) || [null, t])[1].trim();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── git, read-only and local (X18: never a fetch) ────────────────────────────────────────────────────
|
|
181
|
+
function git(root, args) {
|
|
182
|
+
try { return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024 }); }
|
|
183
|
+
catch { return null; }
|
|
184
|
+
}
|
|
185
|
+
/** When each initiative record first appeared in history: { [id]: ISO time }. One `git log` for the whole folder. */
|
|
186
|
+
function recordsAdded(root, initDir) {
|
|
187
|
+
const rel = path.relative(root, initDir).split(path.sep).join('/');
|
|
188
|
+
const out = git(root, ['log', '--diff-filter=A', '--format=%x01%cI', '--name-only', '--', rel]);
|
|
189
|
+
const added = {};
|
|
190
|
+
if (!out) return added;
|
|
191
|
+
let at = null;
|
|
192
|
+
for (const line of out.split('\n')) {
|
|
193
|
+
if (line.startsWith('\x01')) { at = line.slice(1).trim(); continue; }
|
|
194
|
+
const m = /([^/]+)\.md$/.exec(line.trim());
|
|
195
|
+
if (!m || !at) continue;
|
|
196
|
+
const t = Date.parse(at);
|
|
197
|
+
if (Number.isFinite(t) && (!has(added, m[1]) || t < Date.parse(added[m[1]]))) added[m[1]] = iso(t);
|
|
198
|
+
}
|
|
199
|
+
return added;
|
|
200
|
+
}
|
|
201
|
+
/** The age of the remote refs: the time of the last fetch, read from FETCH_HEAD, or null. Nothing is fetched. */
|
|
202
|
+
function lastFetch(root) {
|
|
203
|
+
const dir = git(root, ['rev-parse', '--git-common-dir']);
|
|
204
|
+
if (!dir) return null;
|
|
205
|
+
try { return iso(fs.statSync(path.resolve(root, dir.trim(), 'FETCH_HEAD')).mtimeMs); } catch { return null; }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── health: injected or derived ──────────────────────────────────────────────────────────────────────
|
|
209
|
+
/** `health` is a function of the ctx, or a map { [id]: { level, reasons } }; else lib/health.js healthOf. */
|
|
210
|
+
function healthFor(health, ctx) {
|
|
211
|
+
let h;
|
|
212
|
+
if (typeof health === 'function') h = health(ctx);
|
|
213
|
+
else if (health && typeof health === 'object' && has(health, ctx.id)) h = health[ctx.id];
|
|
214
|
+
else h = healthLib.healthOf(ctx);
|
|
215
|
+
const level = h && has(SEVERITY, h.level) ? h.level : 'unknown';
|
|
216
|
+
const reasons = (h && Array.isArray(h.reasons) ? h.reasons : []).filter((r) => r && isStr(r.text) && r.text.trim())
|
|
217
|
+
.map((r) => ({ level: has(SEVERITY, r.level) ? r.level : 'unknown', code: String(r.code || ''), text: r.text.trim(), source: String(r.source || '') }));
|
|
218
|
+
return { level, reasons };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── the fold ────────────────────────────────────────────────────────────────────────────────────────
|
|
222
|
+
function draftOf(root, { cadence, slot, now, health } = {}) {
|
|
223
|
+
if (!isStr(cadence) || !CADENCE_RE.test(cadence)) return refuse(400, 'cadence must be a report id such as biweekly');
|
|
224
|
+
const nowMs = now == null ? Date.now() : toMs(now);
|
|
225
|
+
if (!Number.isFinite(nowMs)) return refuse(400, 'now must be a time');
|
|
226
|
+
const theSlot = slot == null ? dayOf(nowMs) : slot;
|
|
227
|
+
if (!isDate(theSlot)) return refuse(400, 'slot must be a date, YYYY-MM-DD');
|
|
228
|
+
let E;
|
|
229
|
+
try { E = engine(); } catch (e) { return refuse(500, String((e && e.message) || e).slice(0, 200)); }
|
|
230
|
+
const abs = path.resolve(String(root));
|
|
231
|
+
const L = E.layout(abs);
|
|
232
|
+
if (!isDir(L.initiatives)) return refuse(404, `${abs} holds no initiative records; name a PMOS repository with --root`);
|
|
233
|
+
const main = planTree.mainTree(abs);
|
|
234
|
+
const relMain = (f) => path.relative(main, f).split(path.sep).join('/');
|
|
235
|
+
const relHere = (f) => path.relative(abs, f).split(path.sep).join('/');
|
|
236
|
+
const rules = readRules(abs);
|
|
237
|
+
const cycle = rules.report_cycle_days;
|
|
238
|
+
const def = readDefinition(abs, cadence);
|
|
239
|
+
const rel = draftPath(abs, cadence, theSlot);
|
|
240
|
+
|
|
241
|
+
// The window: since the last issue of this cadence before this slot (since_last_issue), else report_cycle_days back.
|
|
242
|
+
const iss = issues.readIssues(abs);
|
|
243
|
+
const prior = iss.issues.filter((r) => r.cadence === cadence && r.slot < theSlot)
|
|
244
|
+
.reduce((a, b) => (!a || String(b.at) >= String(a.at) ? b : a), null);
|
|
245
|
+
const fromMs = prior ? Date.parse(prior.at) : nowMs - cycle * DAY;
|
|
246
|
+
const window = { from: prior ? prior.at : iso(fromMs), to: iso(nowMs), since: prior ? 'last_issue' : 'cycle_days' };
|
|
247
|
+
const inWin = (at) => { const t = toMs(at); return Number.isFinite(t) && t > fromMs && t <= nowMs; };
|
|
248
|
+
const issuedHere = iss.issues.find((r) => r.cadence === cadence && r.slot === theSlot) || null;
|
|
249
|
+
|
|
250
|
+
// The work-lane records (D86 point 4: the lanes scripts/lane-defs.js groups as work; never a hard-coded list).
|
|
251
|
+
const defs = E.lanesFor(abs).defs || {};
|
|
252
|
+
const range = E.historyRange(abs);
|
|
253
|
+
const workRows = E.listInitiatives(abs).map((id) => { try { return E.computeWorkflow(id, { root: abs, range }); } catch { return null; } })
|
|
254
|
+
.filter((r) => r && r.ok && has(defs, r.lane) && defs[r.lane].group === 'work');
|
|
255
|
+
// A block's note marker carries its id, and readNotes reads back only NOTE_ID_RE ids: a record named outside that
|
|
256
|
+
// class would lose its note on the next Redraft, so it gets no block and Sources names it instead.
|
|
257
|
+
const rows = workRows.filter((r) => NOTE_ID_RE.test(r.id));
|
|
258
|
+
const noMarker = workRows.filter((r) => !NOTE_ID_RE.test(r.id)).map((r) => r.id).sort(byText);
|
|
259
|
+
const titleOf = new Map(rows.map((r) => [r.id, r.title || r.id]));
|
|
260
|
+
|
|
261
|
+
const fold = planItems.readFold(abs, { now: nowMs });
|
|
262
|
+
const plc = placements.readPlacements(abs, { now: nowMs, rules, records: rows });
|
|
263
|
+
const mea = measurements.readMeasurements(abs);
|
|
264
|
+
const fj = factoryJoin.joinFactory(abs);
|
|
265
|
+
const acc = readJsonl(path.join(L.runsDir, 'acceptances.jsonl'));
|
|
266
|
+
const evs = readJsonl(path.join(L.runsDir, 'evals.jsonl'));
|
|
267
|
+
const life = readJsonl(path.join(L.runsDir, 'lifecycle.jsonl'));
|
|
268
|
+
const added = recordsAdded(abs, L.initiatives);
|
|
269
|
+
let existing = null;
|
|
270
|
+
try { existing = fs.readFileSync(path.join(abs, rel), 'utf8'); } catch { existing = null; }
|
|
271
|
+
const notes = readNotes(existing);
|
|
272
|
+
|
|
273
|
+
const itemsOf = (id) => fold.order.map((x) => fold.items[x]).filter((it) => it && it.initiative === id);
|
|
274
|
+
const blocks = [];
|
|
275
|
+
for (const row of rows) {
|
|
276
|
+
const id = row.id;
|
|
277
|
+
const its = itemsOf(id);
|
|
278
|
+
const placement = has(plc.byInit, id) ? plc.byInit[id] : null;
|
|
279
|
+
const horizon = placements.horizonOf(plc, id);
|
|
280
|
+
const moves = plc.moves.filter((m) => m.init === id && inWin(m.at));
|
|
281
|
+
const gates = [
|
|
282
|
+
...acc.rows.filter((a) => a.initiative_id === id && a.accepted === true && inWin(a.accepted_at || a.at)).map((a) => ({ gate: 'acceptance', at: String(a.accepted_at || a.at) })),
|
|
283
|
+
// A review gate passed only on a passing verdict (review-gate.js: FAIL and SKIPPED never count); eval rows carry recorded_at.
|
|
284
|
+
...evs.rows.filter((e) => e.initiative_id === id && reviewGate.isReviewPass(e.verdict, e.score) && inWin(e.recorded_at || e.at))
|
|
285
|
+
.map((e) => ({ gate: 'review', at: String(e.recorded_at || e.at) })),
|
|
286
|
+
].sort((a, b) => byText(a.at, b.at) || byText(a.gate, b.gate));
|
|
287
|
+
const lifeHere = life.rows.filter((l) => l.initiative_id === id && inWin(l.at));
|
|
288
|
+
const touched = its.some((it) => it.history.some((h) => inWin(h.at)));
|
|
289
|
+
if (row.stopped && Number.isFinite(toMs(row.stopped.at)) && toMs(row.stopped.at) <= fromMs) continue;
|
|
290
|
+
const active = (placement && horizon === 'now') || touched || moves.length || gates.length || lifeHere.length;
|
|
291
|
+
if (!active) continue;
|
|
292
|
+
|
|
293
|
+
const narrowed = { items: Object.fromEntries(its.map((it) => [it.id, it])), order: its.map((it) => it.id),
|
|
294
|
+
batches: (fold.batches || []).filter((b) => b.initiative === id), flags: (fold.flags || []).filter((f) => f.initiative === id) };
|
|
295
|
+
const lastAcc = acc.rows.filter((a) => a.initiative_id === id && a.accepted === true).pop() || null;
|
|
296
|
+
const measurement = row.anchor ? measurements.latest(mea, String(row.anchor).split(' ')[0]) : null;
|
|
297
|
+
const factory = factoryJoin.factoryOf(fj, id);
|
|
298
|
+
const ctx = { id, row, fold: narrowed, placement, measurement, factory, rules, now: nowMs };
|
|
299
|
+
const h = healthFor(health, ctx);
|
|
300
|
+
if ((h.level === 'off' || h.level === 'at-risk') && !h.reasons.some((r) => r.level === h.level)) {
|
|
301
|
+
return refuse(422, `${id} reads ${LEVEL_WORD[h.level].toLowerCase()} with no reason. A block that is off track or at risk must say why, so no draft was made`);
|
|
302
|
+
}
|
|
303
|
+
const disp = display.displayOf(row, {
|
|
304
|
+
lifecycle: row.stopped ? { state: row.stopped.state, reason: row.stopped.reason || null, at: row.stopped.at || null } : null,
|
|
305
|
+
acceptance: lastAcc ? { at: String(lastAcc.accepted_at || lastAcc.at || ''), by: actorLib.pageName(lastAcc.by || (lastAcc.pm ? `pm:${lastAcc.pm}` : null)) } : null,
|
|
306
|
+
handoff: null, fold: narrowed, placement, rules, now: nowMs,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const integrated = new Set(((factory && factory.items) || []).filter((w) => w && w.integrated && w.plan_item).map((w) => w.plan_item));
|
|
310
|
+
const real = its.filter((it) => !it.proposed);
|
|
311
|
+
const isDone = (it) => it.state === 'done' || integrated.has(it.id);
|
|
312
|
+
const doneNow = real.filter((it) => it.state === 'done' && inWin(it.doneAt));
|
|
313
|
+
const done = real.filter(isDone).length;
|
|
314
|
+
const today = dayOf(nowMs);
|
|
315
|
+
const horizonEnd = dayOf(nowMs + cycle * DAY);
|
|
316
|
+
const open = real.filter((it) => !isDone(it));
|
|
317
|
+
const carried = open.filter((it) => it.due && it.due < today)
|
|
318
|
+
.sort((a, b) => byText(a.due, b.due) || byText(a.id, b.id)).map((it) => ({ id: it.id, title: it.title, due: it.due }));
|
|
319
|
+
const stageIds = planTree.stageIdsFor(abs, id);
|
|
320
|
+
const stageIdx = (s) => { const k = stageIds.indexOf(s); return k === -1 ? stageIds.length : k; };
|
|
321
|
+
const ordered = open.slice().sort((a, b) => (stageIdx(a.stage) - stageIdx(b.stage)) || (a.order - b.order)
|
|
322
|
+
|| byText(String(a.createdAt), String(b.createdAt)) || byText(a.id, b.id));
|
|
323
|
+
const dated = ordered.filter((it) => [it.start, it.due].some((d) => d && d >= today && d <= horizonEnd));
|
|
324
|
+
const coming = dated.length ? { basis: 'dates', list: dated } : { basis: 'order', list: ordered.slice(0, 3) };
|
|
325
|
+
const m = measurement && SOURCE_RE.test(String(measurement.source)) ? measurement : null;
|
|
326
|
+
|
|
327
|
+
blocks.push({
|
|
328
|
+
id, title: titleOf.get(id),
|
|
329
|
+
stage: cap(disp.stage || row.stage || 'unknown'),
|
|
330
|
+
health: { level: h.level, reasons: h.reasons },
|
|
331
|
+
progress: { done, total: real.length, delta: doneNow.length,
|
|
332
|
+
label: real.length ? `${done} of ${real.length} (+${doneNow.length})` : planTree.progressLabel(0, 0, its.length - real.length) },
|
|
333
|
+
measure: m ? { anchor: String(row.anchor).split(' ')[0], value: m.value, target: m.target, unit: m.unit, as_of: m.as_of, source: m.source } : null,
|
|
334
|
+
last_cycle: {
|
|
335
|
+
done: doneNow.map((it) => ({ id: it.id, title: it.title })),
|
|
336
|
+
gates,
|
|
337
|
+
moves: moves.map((mv) => ({ from: mv.from, to: mv.to, at: mv.at })),
|
|
338
|
+
carried,
|
|
339
|
+
},
|
|
340
|
+
coming_cycle: { basis: coming.basis, items: coming.list.map((it) => ({ id: it.id, title: it.title, start: it.start || null, due: it.due || null })) },
|
|
341
|
+
note: has(notes, id) ? notes[id] : '',
|
|
342
|
+
_order: [SEVERITY[h.level], HORIZONS.indexOf(horizon), placement ? placement.rank : Infinity],
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
// Off track and at risk first; then Now, Next, Later in their Roadmap order; then the id.
|
|
346
|
+
blocks.sort((a, b) => (b._order[0] - a._order[0]) || (a._order[1] - b._order[1]) || (a._order[2] - b._order[2]) || byText(a.id, b.id));
|
|
347
|
+
for (const b of blocks) delete b._order;
|
|
348
|
+
|
|
349
|
+
// The Timeline strip, frozen at fold time: one row per block with dated items, one span per stage.
|
|
350
|
+
const timeline = { from: dayOf(fromMs), to: dayOf(nowMs + cycle * DAY), today: dayOf(nowMs), rows: [] };
|
|
351
|
+
for (const b of blocks) {
|
|
352
|
+
const spans = itemSpans.spansOf(itemsOf(b.id).filter((it) => !it.proposed));
|
|
353
|
+
if (!spans.length) continue;
|
|
354
|
+
const stageIds = planTree.stageIdsFor(abs, b.id);
|
|
355
|
+
const byStage = new Map();
|
|
356
|
+
for (const s of spans) { const k = s.stage || ''; if (!byStage.has(k)) byStage.set(k, []); byStage.get(k).push(s); }
|
|
357
|
+
const keys = [...byStage.keys()].sort((x, y) => ((stageIds.indexOf(x) + 1 || 99) - (stageIds.indexOf(y) + 1 || 99)) || byText(x, y));
|
|
358
|
+
timeline.rows.push({
|
|
359
|
+
id: b.id, title: b.title,
|
|
360
|
+
spans: keys.map((k) => ({ stage: k || null, ...itemSpans.initiativeSpan(byStage.get(k)), milestone: false })),
|
|
361
|
+
milestones: [...new Set(spans.filter((s) => s.milestone).map((s) => s.due))].sort(),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const newSince = rows.filter((r) => has(added, r.id) && inWin(added[r.id]))
|
|
366
|
+
.map((r) => ({ id: r.id, title: titleOf.get(r.id), at: added[r.id] })).sort((a, b) => byText(a.at, b.at) || byText(a.id, b.id));
|
|
367
|
+
|
|
368
|
+
// Decided this cycle: the PM's own acts in the window, after the fact. A pending decision (an agent's proposal, a
|
|
369
|
+
// batch not kept yet) is never here and never in the body: it reaches the PM as a Cycle review row (R22.4).
|
|
370
|
+
const work = new Set(rows.map((r) => r.id));
|
|
371
|
+
const decided = [];
|
|
372
|
+
const plcRel = relMain(ledgers.ledgerPath(abs, 'placements'));
|
|
373
|
+
for (const mv of plc.moves) {
|
|
374
|
+
if (work.has(mv.init) && inWin(mv.at) && isStr(mv.by) && mv.by.startsWith('pm:')) decided.push({ text: `${titleOf.get(mv.init)} moved to ${HORIZON_WORD[mv.to] || mv.to}`, at: mv.at, source: plcRel });
|
|
375
|
+
}
|
|
376
|
+
for (const l of life.rows) {
|
|
377
|
+
if (work.has(l.initiative_id) && inWin(l.at) && has(LIFECYCLE_WORD, l.state)) decided.push({ text: `${titleOf.get(l.initiative_id)} ${LIFECYCLE_WORD[l.state]}`, at: String(l.at), source: relHere(path.join(L.runsDir, 'lifecycle.jsonl')) });
|
|
378
|
+
}
|
|
379
|
+
for (const a of acc.rows) {
|
|
380
|
+
const at = a.accepted_at || a.at;
|
|
381
|
+
if (work.has(a.initiative_id) && a.accepted === true && inWin(at)) decided.push({ text: `Acceptance recorded for ${titleOf.get(a.initiative_id)}`, at: String(at), source: relHere(path.join(L.runsDir, 'acceptances.jsonl')) });
|
|
382
|
+
}
|
|
383
|
+
const kept = new Map();
|
|
384
|
+
for (const id of fold.order) {
|
|
385
|
+
const it = fold.items[id];
|
|
386
|
+
if (!it || !work.has(it.initiative)) continue;
|
|
387
|
+
for (const hh of it.history) if (hh.ev === 'kept' && inWin(hh.at) && isStr(hh.by) && hh.by.startsWith('pm:')) {
|
|
388
|
+
const k = kept.get(hh.eid) || { init: it.initiative, at: hh.at, n: 0 };
|
|
389
|
+
k.n++; kept.set(hh.eid, k);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const piRel = relMain(ledgers.ledgerPath(abs, 'plan-items'));
|
|
393
|
+
for (const k of kept.values()) decided.push({ text: `${titleOf.get(k.init)}: ${plural(k.n, 'item', 'items')} an agent proposed kept`, at: k.at, source: piRel });
|
|
394
|
+
decided.sort((a, b) => byText(a.at, b.at) || byText(a.text, b.text));
|
|
395
|
+
|
|
396
|
+
// Needs your call: the agent's asks to the named audience only (X23), read back from the draft's own note.
|
|
397
|
+
const asks = def.audience && has(notes, ASKS_NOTE)
|
|
398
|
+
? notes[ASKS_NOTE].split('\n').map((l) => /^\s*[-*]\s+(.+)$/.exec(l)).filter(Boolean).map((mm) => ({ text: mm[1].trim(), audience: def.audience }))
|
|
399
|
+
: [];
|
|
400
|
+
|
|
401
|
+
// Sources reached and not reached. Local files only; the remote refs are named with their age and never fetched.
|
|
402
|
+
const reached = [];
|
|
403
|
+
const notReached = [];
|
|
404
|
+
for (const name of ['plan-items', 'placements', 'measurements', 'issues']) {
|
|
405
|
+
const f = ledgers.ledgerPath(abs, name);
|
|
406
|
+
if (fs.existsSync(f)) reached.push(relMain(f));
|
|
407
|
+
}
|
|
408
|
+
for (const [f, r] of [['acceptances.jsonl', acc], ['evals.jsonl', evs], ['lifecycle.jsonl', life]]) {
|
|
409
|
+
if (r.exists) reached.push(relHere(path.join(L.runsDir, f)));
|
|
410
|
+
if (r.bad) notReached.push({ source: relHere(path.join(L.runsDir, f)), why: `${plural(r.bad, 'line', 'lines')} could not be read` });
|
|
411
|
+
}
|
|
412
|
+
if (def.exists) reached.push(def.path);
|
|
413
|
+
else notReached.push({ source: def.path, why: 'no definition; the default frame is used and no audience is named' });
|
|
414
|
+
for (const e of def.errors) if (def.exists) notReached.push({ source: def.path, why: e });
|
|
415
|
+
const unreadable = (list, rel2) => { const n = list.filter((f) => f.kind === 'unreadable' || f.kind === 'unparseable').length; if (n) notReached.push({ source: rel2, why: `${plural(n, 'line', 'lines')} could not be read` }); };
|
|
416
|
+
unreadable(fold.flags || [], piRel);
|
|
417
|
+
unreadable(plc.flags || [], plcRel);
|
|
418
|
+
unreadable(mea.flags || [], relMain(ledgers.ledgerPath(abs, 'measurements')));
|
|
419
|
+
unreadable(iss.flags || [], relMain(ledgers.ledgerPath(abs, 'issues')));
|
|
420
|
+
// A tracker is refreshed by the tracker-refresh skill (an agent, D71), never by this script (X19). Its evidence is a
|
|
421
|
+
// MOVE of a tracker-owned item that carries `file:` evidence outside what PMOS writes (the skill's rule 8). Any other
|
|
422
|
+
// plan-items line (a set, a PM move, a move citing a ledger) is not evidence that the tracker was read: counting it
|
|
423
|
+
// would let the ledger confirm itself.
|
|
424
|
+
const pmosWritten = (ref) => /\.jsonl$/.test(ref) || /(^|\/)(runs|reports)\//.test(ref);
|
|
425
|
+
const isEvidence = (h) => h.ev === 'moved' && h.evidence && h.evidence.kind === 'file' && isStr(h.evidence.ref) && !pmosWritten(h.evidence.ref);
|
|
426
|
+
const trackerItems = fold.order.map((x) => fold.items[x]).filter((it) => it && work.has(it.initiative) && isStr(it.owner) && it.owner.startsWith('tracker:'));
|
|
427
|
+
if (trackerItems.length) {
|
|
428
|
+
const seen = trackerItems.flatMap((it) => it.history.filter(isEvidence).map((h) => String(h.at))).sort();
|
|
429
|
+
const lastSeen = seen.length ? seen.pop() : null;
|
|
430
|
+
if (lastSeen && inWin(lastSeen)) reached.push('tracker (file evidence on tracker-owned moves in plan-items)');
|
|
431
|
+
else notReached.push({ source: 'tracker', why: lastSeen ? `not refreshed since ${short(lastSeen)}` : 'no refresh with file evidence on record' });
|
|
432
|
+
}
|
|
433
|
+
const fetched = lastFetch(abs);
|
|
434
|
+
if (fetched) reached.push(`remote refs (last fetch ${short(fetched)}; not fetched for this draft)`);
|
|
435
|
+
else notReached.push({ source: 'remote refs', why: 'never fetched; this draft reads local refs only' });
|
|
436
|
+
for (const id of noMarker) notReached.push({ source: relHere(path.join(L.initiatives, `${id}.md`)), why: 'no block: a note needs a record name of lower-case letters, digits, ".", "_" or "-"; rename the record' });
|
|
437
|
+
|
|
438
|
+
const moved = blocks.filter((b) => b.progress.delta || b.last_cycle.done.length || b.last_cycle.gates.length || b.last_cycle.moves.length).length;
|
|
439
|
+
const off = blocks.filter((b) => b.health.level === 'off').length;
|
|
440
|
+
const risk = blocks.filter((b) => b.health.level === 'at-risk').length;
|
|
441
|
+
const headline = !blocks.length ? 'No initiative was active in this window.'
|
|
442
|
+
: [`${plural(moved, 'initiative', 'initiatives')} moved`, off ? `${off} ${off === 1 ? 'is' : 'are'} off track` : null,
|
|
443
|
+
risk ? `${risk} ${risk === 1 ? 'is' : 'are'} at risk` : null].filter(Boolean).join('; ') + '.';
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
ok: true, cadence, slot: theSlot, window, path: rel,
|
|
447
|
+
issued: issuedHere ? { at: issuedHere.at, by: actorLib.pageName(issuedHere.by), sha: issuedHere.sha, path: issuedHere.path } : null,
|
|
448
|
+
headline, timeline, blocks, new_since_last: newSince, decided, needs_your_call: asks,
|
|
449
|
+
sources: { reached, not_reached: notReached, definition: { path: def.exists ? def.path : null, audience: def.audience } },
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── the markdown ────────────────────────────────────────────────────────────────────────────────────
|
|
454
|
+
const esc = (s) => String(s == null ? '' : s).replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
455
|
+
|
|
456
|
+
function render(draft, { notes } = {}) {
|
|
457
|
+
if (!draft || draft.ok === false) return '';
|
|
458
|
+
const n = notes && typeof notes === 'object' ? notes : {};
|
|
459
|
+
const noteOf = (id, fallback) => (has(n, id) ? n[id] : (fallback == null ? '' : fallback));
|
|
460
|
+
const name = cap(draft.cadence);
|
|
461
|
+
const audience = draft.sources && draft.sources.definition ? draft.sources.definition.audience : null;
|
|
462
|
+
const out = [];
|
|
463
|
+
const push = (...l) => out.push(...l);
|
|
464
|
+
push('---', 'type: Reference', `title: "${name} report ${draft.slot}"`,
|
|
465
|
+
`description: "The ${draft.cadence} report for ${draft.slot}. pmos report draft writes every number; prose goes only between the note markers."`,
|
|
466
|
+
`timestamp: ${draft.window.to}`, `cadence: ${draft.cadence}`, `slot: ${draft.slot}`,
|
|
467
|
+
`window: "${draft.window.from} to ${draft.window.to} (${draft.window.since === 'last_issue' ? 'since the last issue' : 'report_cycle_days back'})"`,
|
|
468
|
+
'---', '', `# ${name} report ${draft.slot}`, '');
|
|
469
|
+
|
|
470
|
+
push('## Headline', '', draft.headline, '', noteBlock(HEADLINE_NOTE, noteOf(HEADLINE_NOTE)), '');
|
|
471
|
+
|
|
472
|
+
push('## Timeline', '', `Frozen at ${draft.window.to}. ${short(draft.timeline.from)} to ${short(draft.timeline.to)}; today is ${short(draft.timeline.today)}.`, '');
|
|
473
|
+
if (draft.timeline.rows.length) {
|
|
474
|
+
push('| Initiative | Stage | Start | Due | Milestones |', '|---|---|---|---|---|');
|
|
475
|
+
for (const r of draft.timeline.rows) {
|
|
476
|
+
r.spans.forEach((s, i) => push(`| ${i ? '' : esc(r.title)} | ${esc(s.stage || '-')} | ${s.start || '-'} | ${s.due || '-'} | ${i ? '' : (r.milestones.map(short).join(', ') || '-')} |`));
|
|
477
|
+
}
|
|
478
|
+
} else push('No initiative in this report has dated items.');
|
|
479
|
+
push('');
|
|
480
|
+
|
|
481
|
+
push('## Initiatives', '');
|
|
482
|
+
if (!draft.blocks.length) push('No initiative was active in this window.', '');
|
|
483
|
+
for (const b of draft.blocks) {
|
|
484
|
+
push(`### ${b.title} (${b.id})`, '');
|
|
485
|
+
const reason = b.health.reasons.find((r) => r.level === b.health.level);
|
|
486
|
+
push(`Stage: ${b.stage} · ${LEVEL_WORD[b.health.level]}${reason ? `: ${reason.text}` : ''} · ${b.progress.label}`);
|
|
487
|
+
if (b.measure) push('', `Measured: ${b.measure.anchor} ${b.measure.value}${b.measure.unit || ''}${b.measure.target == null ? '' : ` of ${b.measure.target}${b.measure.unit || ''}`}, as of ${b.measure.as_of} (${b.measure.source})`);
|
|
488
|
+
push('', '**Last cycle**', '');
|
|
489
|
+
const lc = b.last_cycle;
|
|
490
|
+
push(`- Done: ${lc.done.length ? lc.done.map((x) => `${x.title} (${x.id})`).join('; ') : 'none'}`);
|
|
491
|
+
push(`- Gates passed: ${lc.gates.length ? lc.gates.map((g) => `${g.gate}, ${short(g.at)}`).join('; ') : 'none'}`);
|
|
492
|
+
push(`- Horizon moves: ${lc.moves.length ? lc.moves.map((m) => `${HORIZON_WORD[m.from] || 'none'} to ${HORIZON_WORD[m.to] || m.to}, ${short(m.at)}`).join('; ') : 'none'}`);
|
|
493
|
+
push(`- Overdue, carried over: ${lc.carried.length ? lc.carried.map((x) => `${x.title}, due ${short(x.due)}`).join('; ') : 'none'}`);
|
|
494
|
+
push('', `**Coming cycle** (${b.coming_cycle.basis === 'dates' ? 'items that start or fall due' : 'the next 3 by order'})`, '');
|
|
495
|
+
if (!b.coming_cycle.items.length) push('- none');
|
|
496
|
+
for (const x of b.coming_cycle.items) push(`- ${x.title}${x.start ? `, starts ${short(x.start)}` : ''}${x.due ? `, due ${short(x.due)}` : ''}`);
|
|
497
|
+
push('', noteBlock(b.id, noteOf(b.id, b.note)), '');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
push('## New since last report', '');
|
|
501
|
+
if (!draft.new_since_last.length) push('None.');
|
|
502
|
+
for (const x of draft.new_since_last) push(`- ${x.title} (${x.id}), ${short(x.at)}`);
|
|
503
|
+
push('');
|
|
504
|
+
|
|
505
|
+
push('## Decided this cycle', '');
|
|
506
|
+
if (!draft.decided.length) push('None.');
|
|
507
|
+
for (const x of draft.decided) push(`- ${x.text}, ${short(x.at)} (${x.source})`);
|
|
508
|
+
push('');
|
|
509
|
+
|
|
510
|
+
push('## Needs your call', '');
|
|
511
|
+
if (audience) push(`For ${audience} only.`, '', noteBlock(ASKS_NOTE, noteOf(ASKS_NOTE)));
|
|
512
|
+
else push('The definition names no audience, so this report asks nothing. The Cycle review card asks the PM.');
|
|
513
|
+
push('');
|
|
514
|
+
|
|
515
|
+
push('## Sources reached and not reached', '', '**Reached**', '');
|
|
516
|
+
if (!draft.sources.reached.length) push('- none');
|
|
517
|
+
for (const s of draft.sources.reached) push(`- ${s}`);
|
|
518
|
+
push('', '**Not reached**', '');
|
|
519
|
+
if (!draft.sources.not_reached.length) push('- none');
|
|
520
|
+
for (const s of draft.sources.not_reached) push(`- ${s.source}: ${s.why}`);
|
|
521
|
+
push('');
|
|
522
|
+
|
|
523
|
+
// Every note is kept, byte for byte: a note whose initiative left the window moves here instead of being lost.
|
|
524
|
+
const placed = new Set([HEADLINE_NOTE, ...(audience ? [ASKS_NOTE] : []), ...draft.blocks.map((b) => b.id)]);
|
|
525
|
+
const orphans = Object.keys(n).filter((k) => !placed.has(k)).sort(byText);
|
|
526
|
+
if (orphans.length) {
|
|
527
|
+
push('## Notes kept from an earlier draft', '', 'These initiatives are not in this window. Their notes are kept as they were written.', '');
|
|
528
|
+
for (const k of orphans) push(noteBlock(k, n[k]), '');
|
|
529
|
+
}
|
|
530
|
+
return out.join('\n');
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ── the one writer of the draft file ───────────────────────────────────────────────────────────────────
|
|
534
|
+
/**
|
|
535
|
+
* Write `draft` as markdown, keeping every note. `notes` defaults to the notes of the file on disk, so a Redraft that
|
|
536
|
+
* passes none still keeps them. `fresh: true` (`pmos report draft` without --redraft) refuses a slot that has a draft
|
|
537
|
+
* already (409), from local state only (X18). An issued slot is frozen: 409.
|
|
538
|
+
*/
|
|
539
|
+
function writeDraft(root, draft, { actor, notes, fresh } = {}) {
|
|
540
|
+
if (!draft || draft.ok === false) return draft && draft.status ? draft : refuse(400, 'there is no draft to write');
|
|
541
|
+
if (!actorLib.valid(actor)) return refuse(403, 'a draft is written by an actor from the call path; nothing was written');
|
|
542
|
+
if (!isStr(draft.cadence) || !CADENCE_RE.test(draft.cadence) || !isDate(draft.slot)) return refuse(400, 'the draft has no valid cadence and slot');
|
|
543
|
+
const abs = path.resolve(String(root));
|
|
544
|
+
if (!isDir(abs)) return refuse(404, `${abs} is not a directory; nothing was written`);
|
|
545
|
+
let rel;
|
|
546
|
+
try { rel = draftPath(abs, draft.cadence, draft.slot); } catch (e) { return refuse(500, String((e && e.message) || e).slice(0, 200)); }
|
|
547
|
+
if (draft.path !== rel) return refuse(400, `the draft names ${draft.path}; this repository keeps it at ${rel}`);
|
|
548
|
+
const iss = issues.readIssues(abs);
|
|
549
|
+
if (issues.isIssued(iss, draft.cadence, draft.slot)) return refuse(409, `${draft.cadence} ${draft.slot} is issued and frozen. Nothing was written. Draft the next slot.`);
|
|
550
|
+
const file = path.join(abs, rel);
|
|
551
|
+
let st = null;
|
|
552
|
+
try { st = fs.lstatSync(file); } catch { st = null; }
|
|
553
|
+
if (st && !st.isFile()) return refuse(403, `${rel} is not a regular file; nothing was written`);
|
|
554
|
+
if (st && fresh) return refuse(409, `a draft for ${draft.cadence} ${draft.slot} exists at ${rel}. Nothing was written`);
|
|
555
|
+
let kept = notes;
|
|
556
|
+
if (kept == null) { try { kept = readNotes(fs.readFileSync(file, 'utf8')); } catch { kept = {}; } }
|
|
557
|
+
if (!kept || typeof kept !== 'object' || Array.isArray(kept)) return refuse(400, 'notes must be an object of note texts by id; nothing was written');
|
|
558
|
+
// Refuse what readNotes cannot read back byte for byte, so a Redraft never drops or cuts a note (C6).
|
|
559
|
+
const badId = [...(Array.isArray(draft.blocks) ? draft.blocks.map((b) => b && b.id) : []), ...Object.keys(kept)].find((id) => !isStr(id) || !NOTE_ID_RE.test(id));
|
|
560
|
+
if (badId !== undefined) return refuse(400, `${JSON.stringify(badId)} cannot carry a note: use lower-case letters, digits, ".", "_" or "-". Nothing was written`);
|
|
561
|
+
const marked = Object.keys(kept).find((id) => kept[id] != null && MARKER_RE.test(String(kept[id])));
|
|
562
|
+
if (marked !== undefined) return refuse(400, `the note for ${marked} holds a note marker (<!-- pmos:note or <!-- /pmos:note). Remove it from the note. Nothing was written`);
|
|
563
|
+
const md = render(draft, { notes: kept });
|
|
564
|
+
const tmp = `${file}.tmp-${crypto.randomBytes(6).toString('hex')}`;
|
|
565
|
+
try {
|
|
566
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
567
|
+
fs.writeFileSync(tmp, md, { flag: 'wx' });
|
|
568
|
+
fs.renameSync(tmp, file);
|
|
569
|
+
} catch (e) {
|
|
570
|
+
try { fs.unlinkSync(tmp); } catch { /* not made */ }
|
|
571
|
+
return refuse(500, 'could not write the draft; nothing was written: ' + String((e && e.message) || e).slice(0, 160));
|
|
572
|
+
}
|
|
573
|
+
return { ok: true, path: rel, sha: issues.blobSha(Buffer.from(md, 'utf8')) };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
module.exports = {
|
|
577
|
+
draftOf, render, writeDraft, readNotes, noteBlock, NOTE_OPEN, NOTE_CLOSE, NOTE_RE, NOTE_ID_RE,
|
|
578
|
+
readDefinition, stateRel, draftPath, definitionPath, FRAME, HEADLINE_NOTE, ASKS_NOTE,
|
|
579
|
+
};
|