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.
Files changed (212) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  3. package/payload/mods/pmos-cockpit/README.md +13 -0
  4. package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
  5. package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
  6. package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
  7. package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
  8. package/payload/planning/evals/eval-calibration.md +1 -1
  9. package/payload/planning/okrs/health-budgets.md +1 -1
  10. package/payload/scripts/plan-items-stage.js +271 -0
  11. package/payload/scripts/pmos-log.sh +18 -7
  12. package/payload/scripts/workflow-state.js +28 -2
  13. package/payload/state/discoveries.md +1 -1
  14. package/payload/state/log.md +1 -1
  15. package/workspace/bin/pmos.js +15 -1
  16. package/workspace/cut-stub.js +40 -0
  17. package/workspace/first-paint-audit.js +145 -0
  18. package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
  19. package/workspace/fixtures/initiatives-api/list.json +176 -0
  20. package/workspace/fixtures/initiatives-api/post-403.json +5 -0
  21. package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
  22. package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
  23. package/workspace/fixtures/initiatives-api/sheet.json +112 -0
  24. package/workspace/fixtures/report-api/draft.json +177 -0
  25. package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
  26. package/workspace/fixtures/today-api/provider-rows.json +204 -0
  27. package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
  28. package/workspace/guide/board.md +81 -0
  29. package/workspace/guide/breakdown.md +154 -0
  30. package/workspace/guide/command-palette.md +75 -0
  31. package/workspace/guide/index.md +27 -17
  32. package/workspace/guide/initiatives-roadmap.md +130 -0
  33. package/workspace/guide/initiatives-screen.md +89 -0
  34. package/workspace/guide/initiatives-timeline.md +57 -0
  35. package/workspace/guide/integrated-work.md +94 -0
  36. package/workspace/guide/knowledge-graph.md +3 -68
  37. package/workspace/guide/layout.md +98 -32
  38. package/workspace/guide/live-notes.md +3 -3
  39. package/workspace/guide/pm-only-acts.md +89 -0
  40. package/workspace/guide/pm-only-lines.md +61 -0
  41. package/workspace/guide/reports-page.md +80 -0
  42. package/workspace/guide/reports.md +73 -0
  43. package/workspace/guide/timeline.md +70 -0
  44. package/workspace/guide/today.md +85 -0
  45. package/workspace/guide/validate-with-typesafe.md +1 -1
  46. package/workspace/lib/actor.js +91 -0
  47. package/workspace/lib/board-cache.js +32 -2
  48. package/workspace/lib/board-cli.js +108 -0
  49. package/workspace/lib/board-fold.js +232 -0
  50. package/workspace/lib/breakdown-export.js +80 -0
  51. package/workspace/lib/capture.js +98 -0
  52. package/workspace/lib/engine/plan-items-stage.js +271 -0
  53. package/workspace/lib/engine/workflow-state.js +28 -2
  54. package/workspace/lib/factory-join.js +158 -0
  55. package/workspace/lib/grant-mint.js +113 -0
  56. package/workspace/lib/health-rules-factory.js +92 -0
  57. package/workspace/lib/health-rules-plan.js +164 -0
  58. package/workspace/lib/health.js +79 -0
  59. package/workspace/lib/initiative-cli.js +222 -0
  60. package/workspace/lib/initiative-display.js +183 -0
  61. package/workspace/lib/initiative-needs.js +127 -0
  62. package/workspace/lib/initiative-rows.js +206 -0
  63. package/workspace/lib/initiative-writer.js +141 -0
  64. package/workspace/lib/issues.js +206 -0
  65. package/workspace/lib/item-cli.js +195 -0
  66. package/workspace/lib/item-spans.js +137 -0
  67. package/workspace/lib/measure-cli.js +100 -0
  68. package/workspace/lib/measurements.js +189 -0
  69. package/workspace/lib/mobile-nav.js +57 -53
  70. package/workspace/lib/needs-providers.js +120 -0
  71. package/workspace/lib/placements.js +440 -0
  72. package/workspace/lib/plan-items.js +922 -0
  73. package/workspace/lib/plan-ledgers.js +85 -0
  74. package/workspace/lib/plan-rules.js +72 -0
  75. package/workspace/lib/plan-tree.js +367 -0
  76. package/workspace/lib/pm-guard.js +174 -0
  77. package/workspace/lib/pm-lines-needs.js +331 -0
  78. package/workspace/lib/pmos-verbs.js +66 -0
  79. package/workspace/lib/proactive.js +7 -4
  80. package/workspace/lib/report-cli.js +152 -0
  81. package/workspace/lib/report-draft.js +579 -0
  82. package/workspace/lib/report-needs.js +213 -0
  83. package/workspace/lib/route-modules.js +128 -0
  84. package/workspace/lib/routes-board.js +91 -0
  85. package/workspace/lib/routes-capture.js +52 -0
  86. package/workspace/lib/routes-initiatives.js +371 -0
  87. package/workspace/lib/routes-reports.js +163 -0
  88. package/workspace/lib/routes-timeline.js +197 -0
  89. package/workspace/lib/routes-today.js +32 -0
  90. package/workspace/lib/today-cli.js +99 -0
  91. package/workspace/lib/today.js +333 -0
  92. package/workspace/lib/triage.js +163 -0
  93. package/workspace/lib/verify-work-cli.js +81 -0
  94. package/workspace/lib/work-check-needs.js +99 -0
  95. package/workspace/package-lock.json +1 -1
  96. package/workspace/package.json +4 -5
  97. package/workspace/public/index.html +29 -19
  98. package/workspace/public/pm/app.js +299 -30
  99. package/workspace/public/pm/bd-board.js +113 -0
  100. package/workspace/public/pm/bd-drag.js +59 -0
  101. package/workspace/public/pm/bd-model.js +152 -0
  102. package/workspace/public/pm/bd-timeline.js +233 -0
  103. package/workspace/public/pm/bd-tree.js +244 -0
  104. package/workspace/public/pm/board-copy.js +57 -0
  105. package/workspace/public/pm/breakdown-api.js +58 -0
  106. package/workspace/public/pm/breakdown-copy.js +138 -0
  107. package/workspace/public/pm/components.css +3054 -0
  108. package/workspace/public/pm/cycle-review.js +125 -0
  109. package/workspace/public/pm/data.js +40 -187
  110. package/workspace/public/pm/dest-board.js +438 -0
  111. package/workspace/public/pm/dest-initiatives.js +134 -0
  112. package/workspace/public/pm/dest-reports.js +270 -0
  113. package/workspace/public/pm/dest-settings.js +217 -0
  114. package/workspace/public/pm/dest-today.js +176 -0
  115. package/workspace/public/pm/dialogs.js +88 -0
  116. package/workspace/public/pm/display-word.js +56 -0
  117. package/workspace/public/pm/dock.js +105 -0
  118. package/workspace/public/pm/fixture.js +8 -227
  119. package/workspace/public/pm/frame-copy.js +94 -0
  120. package/workspace/public/pm/handoff-box.js +82 -0
  121. package/workspace/public/pm/http.js +88 -0
  122. package/workspace/public/pm/icons.js +53 -0
  123. package/workspace/public/pm/initiative-list.js +218 -0
  124. package/workspace/public/pm/initiative-sheet.js +261 -0
  125. package/workspace/public/pm/initiatives-api.js +59 -0
  126. package/workspace/public/pm/initiatives-copy.js +215 -0
  127. package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
  128. package/workspace/public/pm/initiatives-timeline.js +410 -0
  129. package/workspace/public/pm/initiatives-ui.js +123 -0
  130. package/workspace/public/pm/item-sheet.js +251 -0
  131. package/workspace/public/pm/keep-card.js +204 -0
  132. package/workspace/public/pm/lifecycle.js +7 -1
  133. package/workspace/public/pm/mobile.css +49 -20
  134. package/workspace/public/pm/mobile.js +157 -29
  135. package/workspace/public/pm/modes-live.js +7 -883
  136. package/workspace/public/pm/modes.js +32 -1024
  137. package/workspace/public/pm/new-initiative-sheet.js +74 -0
  138. package/workspace/public/pm/next-step-ui.js +3 -2
  139. package/workspace/public/pm/palette-copy.js +51 -0
  140. package/workspace/public/pm/palette.js +312 -0
  141. package/workspace/public/pm/panel-breakdown.js +702 -0
  142. package/workspace/public/pm/panel-cockpit.js +63 -295
  143. package/workspace/public/pm/panel-detail.js +25 -172
  144. package/workspace/public/pm/panel-initiatives.js +1 -19
  145. package/workspace/public/pm/panel-repos.js +31 -81
  146. package/workspace/public/pm/panel-tabs.js +197 -110
  147. package/workspace/public/pm/panel-trees.js +5 -2
  148. package/workspace/public/pm/portfolio-report.js +61 -0
  149. package/workspace/public/pm/primitives.js +656 -0
  150. package/workspace/public/pm/print.css +33 -0
  151. package/workspace/public/pm/provider-cards.js +136 -0
  152. package/workspace/public/pm/registry.js +194 -0
  153. package/workspace/public/pm/release-card.js +110 -0
  154. package/workspace/public/pm/report-view.js +309 -0
  155. package/workspace/public/pm/reports-copy.js +114 -0
  156. package/workspace/public/pm/roadmap.js +271 -0
  157. package/workspace/public/pm/sig-watch.js +41 -0
  158. package/workspace/public/pm/state.js +8 -4
  159. package/workspace/public/pm/status-banner.js +13 -5
  160. package/workspace/public/pm/tab-doc.js +2 -4
  161. package/workspace/public/pm/tab-term.js +12 -2
  162. package/workspace/public/pm/tab-web.js +3 -2
  163. package/workspace/public/pm/timeline-copy.js +48 -0
  164. package/workspace/public/pm/today-cards.js +244 -0
  165. package/workspace/public/pm/today-copy.js +97 -0
  166. package/workspace/public/pm/tokens.css +16 -18
  167. package/workspace/public/pm/topbar.js +102 -0
  168. package/workspace/public/pm/triage-card.js +60 -0
  169. package/workspace/public/pm/undo-hold.js +103 -0
  170. package/workspace/public/pm/view-addrepo.js +2 -1
  171. package/workspace/public/pm/view-livenotes.js +3 -1
  172. package/workspace/public/pm/view-today.js +7 -162
  173. package/workspace/public/vendor/Sortable.min.js +2 -0
  174. package/workspace/public/vendor/lucide-sprite.js +79 -0
  175. package/workspace/replaced-tests.json +2864 -0
  176. package/workspace/run-tests.js +56 -5
  177. package/workspace/server.js +312 -36
  178. package/workspace/sheet-answer.js +61 -0
  179. package/workspace/skills/measure.skill +41 -0
  180. package/workspace/skills/report-biweekly.skill +23 -25
  181. package/workspace/skills/tracker-refresh.skill +50 -0
  182. package/workspace/test-files.json +19 -3
  183. package/workspace/test-lists/base.json +6 -0
  184. package/workspace/test-lists/run2-a.json +6 -0
  185. package/workspace/test-lists/run2-b.json +13 -0
  186. package/workspace/test-lists/run2-c.json +19 -0
  187. package/workspace/test-lists/run2-d.json +22 -0
  188. package/workspace/test-lists/run3-0.json +5 -0
  189. package/workspace/test-lists/run3-a.json +5 -0
  190. package/workspace/test-lists/run3-b.json +6 -0
  191. package/workspace/test-lists/run3-c.json +15 -0
  192. package/workspace/test-lists/run3-d.json +6 -0
  193. package/workspace/test-lists/run4-a.json +8 -0
  194. package/workspace/test-lists/run4-b.json +6 -0
  195. package/workspace/test-lists/run4-c.json +13 -0
  196. package/workspace/test-lists/run4-d.json +9 -0
  197. package/workspace/test-lists/run5-a.json +6 -0
  198. package/workspace/test-lists/run5-b.json +6 -0
  199. package/workspace/test-lists/run5-c.json +15 -0
  200. package/workspace/test-run.js +27 -1
  201. package/workspace/third-party/README.md +44 -0
  202. package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
  203. package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
  204. package/workspace/design-parity.js +0 -673
  205. package/workspace/public/pm/tab-task.js +0 -86
  206. package/workspace/public/pm/view-guide.js +0 -141
  207. package/workspace/public/pm/view-knowledge.js +0 -225
  208. package/workspace/public/pm/view-memory.js +0 -122
  209. package/workspace/public/pm/view-portfolio.js +0 -153
  210. package/workspace/public/pm/view-reports.js +0 -190
  211. package/workspace/public/pm/view-scheduled.js +0 -161
  212. package/workspace/public/pm/view-skills.js +0 -216
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+ /* item-cli.js — `pmos item …`: an initiative's breakdown items from a terminal (pm-os-absorb Run 1, PRD R24).
3
+ *
4
+ * pmos item add <initiative> "<title>" --stage <id|label> [--due YYYY-MM-DD] [--start YYYY-MM-DD]
5
+ * [--owner you|agent|person:<name>|tracker:<ref>] [--after <id>[,<id>]] [--milestone] [--note "<text>"] [--json]
6
+ * pmos item set <item> [--title ..] [--stage ..] [--due ..|none] [--start ..|none] [--owner ..] [--after ids|none]
7
+ * [--milestone|--no-milestone] [--note ..|none] [--blocked "<reason>"|none] [--json]
8
+ * pmos item move <item> <todo|doing|review|done> [--evidence <commit|pr|file|session|note>:<ref>] [--json]
9
+ * pmos item withdraw <item> [--json]
10
+ * pmos item list <initiative> [--json]
11
+ * common: [--root <dir>]
12
+ *
13
+ * The SAME writer and the SAME fold the workspace page uses (lib/plan-items.js) — no server is needed, and
14
+ * the terminal and the page cannot disagree about an item. Who wrote a line is decided by the call path,
15
+ * never by an argument: `pm:wawan` at an interactive terminal, the bound session from PMOS_SESSION, and
16
+ * `agent:unknown` otherwise. There is no `keep` verb: Keep is the PM's press on the workspace page.
17
+ */
18
+ const path = require('path');
19
+ const { execFileSync } = require('child_process');
20
+ const planItems = require('./plan-items.js');
21
+ const planTree = require('./plan-tree.js');
22
+ const { parseArgs } = require('./schedule-cli');
23
+
24
+ const COMMANDS = { item: ['add', 'set', 'move', 'withdraw', 'list'] };
25
+ const USAGE = [
26
+ 'usage: pmos item add|set|move|withdraw|list — an initiative\'s breakdown items',
27
+ ' pmos item add <initiative> "<title>" --stage <stage> [--due YYYY-MM-DD] [--start YYYY-MM-DD]',
28
+ ' [--owner you|agent|person:<name>|tracker:<ref>] [--after <item>[,<item>]] [--milestone] [--note "<text>"]',
29
+ ' pmos item set <item> [--title "<title>"] [--stage <stage>] [--due <date>|none] [--start <date>|none] [--owner <owner>]',
30
+ ' [--after <items>|none] [--milestone|--no-milestone] [--note "<text>"|none] [--blocked "<reason>"|none]',
31
+ ' pmos item move <item> todo|doing|review|done [--evidence commit|pr|file|session|note:<ref>]',
32
+ ' pmos item withdraw <item>',
33
+ ' pmos item list <initiative>',
34
+ ' every command: [--json] [--root <dir>]',
35
+ 'An item id is <initiative>.<10 characters>. A stage is an id or a label of the initiative\'s stages.',
36
+ 'Who wrote each line comes from where the command runs: you at a terminal, the agent session otherwise.',
37
+ 'Keep is pressed on the workspace page (Breakdown or Today). This command does not write it.',
38
+ ];
39
+ const BOOL = new Set(['json', 'milestone', 'no-milestone', 'help']);
40
+ const VALUE = new Set(['title', 'stage', 'due', 'start', 'owner', 'after', 'note', 'blocked', 'evidence', 'root']);
41
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
42
+ const STATE_LABEL = { todo: 'Todo', doing: 'Doing', review: 'Review', done: 'Done', withdrawn: 'Withdrawn' };
43
+
44
+ function parse(argv) {
45
+ const rest = []; const bools = {};
46
+ for (const a of argv) {
47
+ if (a.startsWith('--') && BOOL.has(a.slice(2))) bools[a.slice(2)] = true;
48
+ else if (a === '-h') bools.help = true;
49
+ else rest.push(a);
50
+ }
51
+ const { pos, flags } = parseArgs(rest);
52
+ return { pos, flags: { ...flags, ...bools } };
53
+ }
54
+
55
+ function repoRoot(cwd) {
56
+ try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; }
57
+ catch { return cwd; }
58
+ }
59
+ const shortDate = (d) => { if (!d) return null; const t = new Date(d + 'T00:00:00Z'); return `${t.getUTCDate()} ${MONTHS[t.getUTCMonth()]}`; };
60
+ const csv = (v) => String(v).split(',').map((s) => s.trim()).filter(Boolean);
61
+
62
+ /** One line about an item, for a person. */
63
+ function describe(it, stageLabel) {
64
+ const bits = [`${it.id} "${it.title}"`, stageLabel || it.stage, STATE_LABEL[it.state] || it.state];
65
+ if (it.milestone) bits.push('milestone');
66
+ if (it.due) bits.push(`due ${shortDate(it.due)}`);
67
+ if (it.blocked) bits.push(`blocked: ${it.blocked.reason}`);
68
+ if (it.proposed) bits.push('proposed');
69
+ return bits.join(' · ');
70
+ }
71
+
72
+ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isTTY, env, now } = {}) {
73
+ const args = argv[0] === 'item' ? argv.slice(1) : argv.slice();
74
+ const verb = args[0];
75
+ const { pos, flags } = parse(args.slice(1));
76
+ const json = !!flags.json;
77
+ if (!verb || verb === 'help' || verb === '--help' || verb === '-h' || flags.help) {
78
+ for (const l of USAGE) say(l);
79
+ return verb ? 0 : 1; // asked for help → 0; no command at all → 1, as `pmos kg` does
80
+ }
81
+ if (verb === 'keep') { say('Keep is written only from the workspace page (Breakdown or Today).'); return 2; }
82
+ if (!COMMANDS.item.includes(verb)) { say(`unknown item command "${verb}" — one of ${COMMANDS.item.join(', ')}`); return 2; }
83
+ if ('by' in flags || 'via' in flags) { say(`attribution is set from the call path; --${'by' in flags ? 'by' : 'via'} is not accepted`); return 2; }
84
+ for (const k of Object.keys(flags)) {
85
+ if (!BOOL.has(k) && !VALUE.has(k)) { say(`unknown option --${k}`); return 2; }
86
+ if (VALUE.has(k) && flags[k] === '') { say(`--${k} needs a value`); return 2; }
87
+ }
88
+
89
+ const root = flags.root ? path.resolve(cwd, flags.root) : repoRoot(cwd);
90
+ const cliOpts = {};
91
+ if (isTTY !== undefined) cliOpts.isTTY = isTTY;
92
+ if (env !== undefined) cliOpts.env = env;
93
+ if (now !== undefined) cliOpts.now = now;
94
+ const fail = (error, code = 1) => { if (json) say(JSON.stringify({ ok: false, error })); else say(`✗ ${error}`); return code; };
95
+ const stageLabelOf = (initiative, id) => { try { const s = planTree.stagesFor(root, initiative).find((x) => x.id === id); return s ? s.label : id; } catch { return id; } };
96
+ const fresh = (id) => planItems.readFold(root, { now }).items[id] || null;
97
+
98
+ if (verb === 'list') {
99
+ const initiative = pos[0];
100
+ if (!initiative) { say('usage: pmos item list <initiative> [--json]'); return 2; }
101
+ if (!planItems.INIT_RE.test(initiative) || !planTree.recordPath(root, initiative)) return fail(`no initiative ${initiative} in this repository`);
102
+ const r = planItems.readFold(root, { now });
103
+ const stages = planTree.stagesFor(root, initiative);
104
+ const idx = (id) => { const i = stages.findIndex((s) => s.id === id); return i === -1 ? stages.length : i; };
105
+ const items = r.order.map((id) => r.items[id]).filter((it) => it.initiative === initiative)
106
+ .sort((a, b) => (idx(a.stage) - idx(b.stage)) || (a.order - b.order) || String(a.createdAt).localeCompare(String(b.createdAt)) || a.id.localeCompare(b.id));
107
+ const progress = planItems.progressOf(r, initiative);
108
+ if (json) {
109
+ const { profile } = planTree.profileAndLane(root, initiative);
110
+ say(JSON.stringify({ ok: true, initiative, profile, progress, items }));
111
+ return 0;
112
+ }
113
+ say(`${initiative} · ${progress.label}`);
114
+ for (const s of [...stages, { id: null, label: 'Other' }]) {
115
+ const mine = items.filter((it) => (s.id === null ? idx(it.stage) === stages.length : it.stage === s.id));
116
+ if (!mine.length) continue;
117
+ const real = mine.filter((it) => !it.proposed);
118
+ say(` ${s.label}${real.length ? ` · ${real.filter((it) => it.state === 'done').length} of ${real.length}` : ''}`);
119
+ for (const it of mine) say(` ${describe(it, s.id ? s.label : it.stage)} · ${it.owner}`);
120
+ }
121
+ return 0;
122
+ }
123
+
124
+ let events; let id = null; let before = null;
125
+ if (verb === 'add') {
126
+ const [initiative, title] = pos;
127
+ if (!initiative || title === undefined) { say('usage: pmos item add <initiative> "<title>" --stage <stage> [options]'); return 2; }
128
+ if (!planItems.INIT_RE.test(initiative) || !planTree.recordPath(root, initiative)) return fail(`no initiative ${initiative} in this repository`);
129
+ if (!flags.stage) {
130
+ const stages = planTree.stagesFor(root, initiative);
131
+ say(`--stage is required — the stages of ${initiative} are: ${stages.map((s) => `${s.id} (${s.label})`).join(', ')}`);
132
+ return 2;
133
+ }
134
+ const ev = { ev: 'planned', initiative, title, stage: flags.stage };
135
+ if (flags.due) ev.due = flags.due;
136
+ if (flags.start) ev.start = flags.start;
137
+ if (flags.owner) ev.owner = flags.owner;
138
+ if (flags.after) ev.after = csv(flags.after);
139
+ if (flags.milestone) ev.milestone = true;
140
+ if (flags.note) ev.note = flags.note;
141
+ for (const k of ['title', 'blocked', 'evidence', 'no-milestone']) if (k in flags) return fail(`--${k} is not an option of add`, 2);
142
+ events = [ev];
143
+ } else {
144
+ id = pos[0];
145
+ if (!id) { say(`usage: pmos item ${verb} <item> …`); return 2; }
146
+ before = (() => { try { return fresh(id); } catch { return null; } })();
147
+ if (verb === 'set') {
148
+ const fields = {};
149
+ if (flags.title) fields.title = flags.title;
150
+ if (flags.stage) fields.stage = flags.stage;
151
+ for (const k of ['due', 'start', 'note']) if (flags[k]) fields[k] = flags[k] === 'none' ? null : flags[k];
152
+ if (flags.owner) fields.owner = flags.owner;
153
+ if (flags.after) fields.after = flags.after === 'none' ? [] : csv(flags.after);
154
+ if (flags.milestone) fields.milestone = true;
155
+ if (flags['no-milestone']) fields.milestone = false;
156
+ if (flags.milestone && flags['no-milestone']) return fail('--milestone and --no-milestone contradict each other', 2);
157
+ if (flags.evidence) return fail('--evidence belongs to move', 2);
158
+ events = [];
159
+ if (Object.keys(fields).length) events.push({ ev: 'set', item: id, fields });
160
+ if (flags.blocked) events.push(flags.blocked === 'none' ? { ev: 'flagged', item: id, blocked: false } : { ev: 'flagged', item: id, blocked: true, reason: flags.blocked });
161
+ if (!events.length) { say('nothing to set — name at least one option (pmos item help)'); return 2; }
162
+ } else if (verb === 'move') {
163
+ const to = String(pos[1] || '').toLowerCase();
164
+ if (!planItems.STATES.includes(to)) { say(`usage: pmos item move <item> ${planItems.STATES.join('|')} [--evidence <kind>:<ref>]`); return 2; }
165
+ for (const k of Object.keys(flags)) if (!['json', 'root', 'evidence'].includes(k)) return fail(`--${k} is not an option of move`, 2);
166
+ const ev = { ev: 'moved', item: id, to };
167
+ if (flags.evidence) ev.evidence = flags.evidence;
168
+ events = [ev];
169
+ } else if (verb === 'withdraw') {
170
+ for (const k of Object.keys(flags)) if (!['json', 'root'].includes(k)) return fail(`--${k} is not an option of withdraw`, 2);
171
+ events = [{ ev: 'withdrawn', item: id }];
172
+ }
173
+ }
174
+
175
+ let r;
176
+ try { r = planItems.appendFromCli(root, events, cliOpts); }
177
+ catch (e) { return fail('could not record — ' + String((e && e.message) || e).slice(0, 200)); }
178
+ if (!r.ok) return fail(r.error);
179
+ const itemId = id || (r.written[0] && r.written[0].item);
180
+ const after = (() => { try { return fresh(itemId); } catch { return null; } })();
181
+ const item = after || (before ? { ...before, state: 'withdrawn' } : null);
182
+ const actor = planItems.actorFromCli({ ...(isTTY !== undefined ? { isTTY } : {}), ...(env !== undefined ? { env } : {}) });
183
+ if (json) { say(JSON.stringify({ ok: true, item })); return 0; }
184
+ const by = planTree.byLabel(actor.by);
185
+ const label = item ? stageLabelOf(item.initiative, item.stage) : '';
186
+ if (verb === 'add') say(`added ${item.id} "${item.title}" · ${label}${item.due ? ` · due ${shortDate(item.due)}` : ''} · by ${by}`);
187
+ else if (verb === 'withdraw') say(`withdrew ${itemId}${before ? ` "${before.title}"` : ''} · by ${by}`);
188
+ else if (verb === 'move') {
189
+ say(`moved ${item.id} "${item.title}" to ${STATE_LABEL[item.state]}${item.evidence ? ` with evidence ${item.evidence.kind}:${item.evidence.ref}` : ''} · by ${by}`);
190
+ if (item.noEvidence) say(' no evidence — an agent Done with no evidence goes to the PM. Add --evidence <kind>:<ref>.');
191
+ } else say(`updated ${describe(item, label)} · by ${by}`);
192
+ return 0;
193
+ }
194
+
195
+ module.exports = { run, COMMANDS, USAGE };
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+ // item-spans.js — the dated bars of a breakdown: one span per item from its start and due, and the initiative's span
3
+ // over them (pm-os-absorb, PRD R20, R8 predecessor flag).
4
+ //
5
+ // spansOf(items, { stages }) → [{ id, stage, start, due, milestone, proposed, overlapsPredecessor, overlap }]
6
+ // initiativeSpan(spans, { proposed }) → { start, due } | null
7
+ // stageSpans(spans, { proposed }) → [{ stage, start, due }] one per stage, in the order of the spans
8
+ // axisOf(spans, { today }) → { start, end, months: [{ month, start, end }] } whole months, at least MIN_MONTHS
9
+ // dayNum(date) · addDays(date, n) · daysBetween(a, b) calendar-day arithmetic on YYYY-MM-DD, never shifted by a zone
10
+ //
11
+ // `items` is the `items` of a plan-items fold or layout (lib/plan-items.js, lib/plan-tree.js): an object keyed by item
12
+ // id, or a list. Dates are YYYY-MM-DD strings. One engine for Breakdown › Timeline and Initiatives › Timeline (Run 4)
13
+ // and the report's Timeline strip (Run 5). Classic script (a UMD setting window.PMOSItemSpans), served from /lib/, so the
14
+ // bars on the screen and the numbers in a report cannot disagree.
15
+ //
16
+ // The rules (Run 4, slice 4B):
17
+ // - A withdrawn item has no span. An item with neither date has no span. An item with one date spans that one day.
18
+ // - Spans come in stage order (`stages`: stage ids or { id } objects, as the layout lists them), then by the item's
19
+ // `order`, then by start. A stage the list does not name sorts last.
20
+ // - `milestone` is the item's own flag. A milestone's ◆ sits on its due date.
21
+ // - `proposed` is the item's own flag: an agent's proposal that the PM has not kept (lib/plan-items.js). It has a span,
22
+ // but initiativeSpan and stageSpans leave it out unless `{ proposed: true }` is passed, so a batch of proposals
23
+ // that nobody kept never stretches a stage or an initiative bar. axisOf includes it, so its bar has room.
24
+ // - `overlapsPredecessor` (R8, at risk): the item starts on or before the day a predecessor ends. A predecessor is an
25
+ // id in the item's `after` list that is among `items`, live and not Done; it ends on its due date (or its start when
26
+ // it has no due). An item that is Done is never flagged. `overlap` names the predecessor that ends last, as
27
+ // { id, due }, so the screen can give the reason; it is null when nothing overlaps.
28
+ // - A predecessor in another initiative counts only when the caller passes its item too. No date is ever moved here:
29
+ // the flag shows a clash, and the PM decides (no cascade, PRD R20).
30
+ (function (root, factory) {
31
+ const api = factory();
32
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
33
+ else root.PMOSItemSpans = api;
34
+ }(typeof self !== 'undefined' ? self : this, function () {
35
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
36
+ const DAY = 86400000;
37
+ const MIN_MONTHS = 3;
38
+ const date = (v) => (typeof v === 'string' && DATE.test(v) ? v : null);
39
+
40
+ /** Days since 1970-01-01 for a YYYY-MM-DD date (NaN for anything else). */
41
+ function dayNum(d) {
42
+ if (!date(d)) return NaN;
43
+ return Math.round(Date.UTC(Number(d.slice(0, 4)), Number(d.slice(5, 7)) - 1, Number(d.slice(8, 10))) / DAY);
44
+ }
45
+ const fromNum = (n) => new Date(n * DAY).toISOString().slice(0, 10);
46
+ /** The date `n` calendar days after `d` (before it when `n` is negative); null for a bad date. */
47
+ function addDays(d, n) { const x = dayNum(d); return Number.isNaN(x) ? null : fromNum(x + Math.round(Number(n) || 0)); }
48
+ /** Whole days from `a` to `b` (b − a). */
49
+ function daysBetween(a, b) { return dayNum(b) - dayNum(a); }
50
+
51
+ const listOf = (items) => (Array.isArray(items) ? items : items && typeof items === 'object' ? Object.values(items) : []);
52
+
53
+ function stageRank(stages) {
54
+ const rank = new Map();
55
+ (Array.isArray(stages) ? stages : []).forEach((s, i) => {
56
+ const id = typeof s === 'string' ? s : s && typeof s.id === 'string' ? s.id : null;
57
+ if (id && !rank.has(id)) rank.set(id, i);
58
+ });
59
+ return (id) => (rank.has(id) ? rank.get(id) : rank.size);
60
+ }
61
+
62
+ function spansOf(items, opts) {
63
+ const rank = stageRank(opts && opts.stages);
64
+ const live = listOf(items).filter((it) => it && typeof it.id === 'string' && it.state !== 'withdrawn');
65
+ const byId = new Map(live.map((it) => [it.id, it]));
66
+ const out = [];
67
+ for (const it of live) {
68
+ const start = date(it.start);
69
+ const due = date(it.due);
70
+ if (!start && !due) continue;
71
+ const s = start || due;
72
+ let overlap = null;
73
+ if (it.state !== 'done') {
74
+ for (const pid of Array.isArray(it.after) ? it.after : []) {
75
+ const p = byId.get(pid);
76
+ if (!p || p.state === 'done') continue;
77
+ const end = date(p.due) || date(p.start);
78
+ if (end && s <= end && (!overlap || end > overlap.due)) overlap = { id: pid, due: end };
79
+ }
80
+ }
81
+ out.push({
82
+ id: it.id, stage: it.stage || null, start: s, due: due || start, milestone: !!it.milestone, proposed: !!it.proposed,
83
+ overlapsPredecessor: !!overlap, overlap,
84
+ _rank: rank(it.stage), _order: Number.isFinite(it.order) ? it.order : Infinity,
85
+ });
86
+ }
87
+ out.sort((a, b) => a._rank - b._rank || a._order - b._order || (a.start < b.start ? -1 : a.start > b.start ? 1 : 0) || (a.id < b.id ? -1 : 1));
88
+ for (const s of out) { delete s._rank; delete s._order; }
89
+ return out;
90
+ }
91
+
92
+ const counted = (spans, opts) => listOf(spans).filter((s) => s && date(s.start) && date(s.due) && (!s.proposed || !!(opts && opts.proposed)));
93
+
94
+ function initiativeSpan(spans, opts) {
95
+ const list = counted(spans, opts);
96
+ if (!list.length) return null;
97
+ let start = list[0].start;
98
+ let due = list[0].due;
99
+ for (const s of list) { if (s.start < start) start = s.start; if (s.due > due) due = s.due; }
100
+ return { start, due };
101
+ }
102
+
103
+ function stageSpans(spans, opts) {
104
+ const by = new Map();
105
+ for (const s of counted(spans, opts)) {
106
+ const k = s.stage || null;
107
+ const g = by.get(k);
108
+ if (!g) by.set(k, { stage: k, start: s.start, due: s.due });
109
+ else { if (s.start < g.start) g.start = s.start; if (s.due > g.due) g.due = s.due; }
110
+ }
111
+ return [...by.values()];
112
+ }
113
+
114
+ /** The time axis for `spans`: whole months from the earliest start to the latest due, with `today` inside it. */
115
+ function axisOf(spans, opts) {
116
+ const today = date(opts && opts.today);
117
+ const whole = initiativeSpan(spans, { proposed: true });
118
+ let lo = whole ? whole.start : today;
119
+ let hi = whole ? whole.due : today;
120
+ if (today) { if (today < lo) lo = today; if (today > hi) hi = today; }
121
+ if (!lo) return null;
122
+ const months = [];
123
+ let y = Number(lo.slice(0, 4));
124
+ let m = Number(lo.slice(5, 7));
125
+ const pad = (n) => String(n).padStart(2, '0');
126
+ for (;;) {
127
+ const first = `${y}-${pad(m)}-01`;
128
+ const next = m === 12 ? `${y + 1}-01-01` : `${y}-${pad(m + 1)}-01`;
129
+ months.push({ month: `${y}-${pad(m)}`, start: first, end: addDays(next, -1) });
130
+ if (next > hi && months.length >= MIN_MONTHS) break;
131
+ if (m === 12) { y++; m = 1; } else m++;
132
+ }
133
+ return { start: months[0].start, end: months[months.length - 1].end, months };
134
+ }
135
+
136
+ return { spansOf, initiativeSpan, stageSpans, axisOf, dayNum, addDays, daysBetween, MIN_MONTHS };
137
+ }));
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+ /* measure-cli.js — `pmos measure …`: record a measured value of a KR or a health budget, with its source (pm-os-absorb
3
+ * Run 2, PRD R17, R23, R24).
4
+ *
5
+ * pmos measure add <anchor> <value> --source <path>@<sha> [--target <number>] [--unit <unit>] [--as-of YYYY-MM-DD]
6
+ * pmos measure list [<anchor>]
7
+ * every command: [--json] [--root <dir>]
8
+ *
9
+ * The SAME writer the workspace page uses (lib/measurements.js). A measurement is evidence, not a PM act, so an agent
10
+ * may write one: the line is attributed by lib/actor.js fromCli (the PM at a terminal, the bound agent session from
11
+ * PMOS_SESSION, `agent:unknown` otherwise). The source is required: the workspace never computes a KR value, so a
12
+ * group shows a value only from a line that says where it was read. `--as-of` is today (UTC) when it is not given.
13
+ * Exit codes: 0 done, 1 refused, 2 usage.
14
+ */
15
+ const path = require('path');
16
+ const { execFileSync } = require('child_process');
17
+ const actorLib = require('./actor.js');
18
+ const measurements = require('./measurements.js');
19
+ const { parseArgs } = require('./schedule-cli');
20
+
21
+ const COMMANDS = { measure: ['add', 'list'] };
22
+ const USAGE = [
23
+ 'usage: pmos measure add|list — measured values of KRs and health budgets',
24
+ ' pmos measure add <anchor> <value> --source <path>@<sha> [--target <number>] [--unit <unit>] [--as-of YYYY-MM-DD]',
25
+ ' pmos measure list [<anchor>]',
26
+ ' every command: [--json] [--root <dir>]',
27
+ 'The anchor is the parent_kr an initiative names: a KR such as "KR 2.1", or health:<id>. Quote an anchor with a space.',
28
+ 'The source is the file the value was read from and the commit it was read at, for example planning/okrs/q4.md@1a2b3c4.',
29
+ ];
30
+ const BOOL = new Set(['json', 'help']);
31
+ const VALUE = new Set(['source', 'target', 'unit', 'as-of', 'root']);
32
+
33
+ function parse(argv) {
34
+ const rest = []; const bools = {};
35
+ for (const a of argv) {
36
+ if (a.startsWith('--') && BOOL.has(a.slice(2))) bools[a.slice(2)] = true;
37
+ else if (a === '-h') bools.help = true;
38
+ else rest.push(a);
39
+ }
40
+ const { pos, flags } = parseArgs(rest);
41
+ return { pos, flags: { ...flags, ...bools } };
42
+ }
43
+ function repoRoot(cwd) {
44
+ try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; }
45
+ catch { return cwd; }
46
+ }
47
+ const num = (s) => (typeof s === 'string' && s.trim() !== '' && Number.isFinite(Number(s)) ? Number(s) : NaN);
48
+
49
+ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), isTTY, env, now } = {}) {
50
+ const args = argv[0] === 'measure' ? argv.slice(1) : argv.slice();
51
+ const verb = args[0];
52
+ const { pos, flags } = parse(args.slice(1));
53
+ const json = !!flags.json;
54
+ if (!verb || verb === 'help' || verb === '--help' || verb === '-h' || flags.help) {
55
+ for (const l of USAGE) say(l);
56
+ return verb ? 0 : 1;
57
+ }
58
+ if (!COMMANDS.measure.includes(verb)) { say(`unknown measure command "${verb}" — one of ${COMMANDS.measure.join(', ')}`); return 2; }
59
+ if ('by' in flags || 'via' in flags) { say(`attribution is set from the call path; --${'by' in flags ? 'by' : 'via'} is not accepted`); return 2; }
60
+ for (const k of Object.keys(flags)) {
61
+ if (!BOOL.has(k) && !VALUE.has(k)) { say(`unknown option --${k}`); return 2; }
62
+ if (VALUE.has(k) && flags[k] === '') { say(`--${k} needs a value`); return 2; }
63
+ }
64
+ const root = flags.root ? path.resolve(cwd, flags.root) : repoRoot(cwd);
65
+ const fail = (error, code = 1) => { if (json) say(JSON.stringify({ ok: false, error })); else say(`✗ ${error}`); return code; };
66
+
67
+ if (verb === 'list') {
68
+ const m = measurements.readMeasurements(root);
69
+ const anchors = pos[0] ? [pos[0]] : Object.keys(m.byAnchor).sort();
70
+ if (json) { say(JSON.stringify({ ok: true, byAnchor: Object.fromEntries(anchors.map((a) => [a, (m.byAnchor[a] || []).map((x) => ({ ...x, by: actorLib.pageName(x.by) }))])), flags: m.flags })); return 0; }
71
+ if (!anchors.length) say('No measurements yet.');
72
+ for (const a of anchors) {
73
+ for (const x of m.byAnchor[a] || []) say(`${a} · ${x.value}${x.unit || ''}${x.target === null ? '' : ` → ${x.target}${x.unit || ''}`} on ${x.as_of} · ${x.source} · ${actorLib.pageName(x.by)}`);
74
+ }
75
+ for (const f of m.flags) say(`! ${f.why}`);
76
+ return 0;
77
+ }
78
+
79
+ const [anchor, valueText] = pos;
80
+ if (!anchor || valueText === undefined) { say(USAGE[1].trim()); return 2; }
81
+ const value = num(valueText);
82
+ if (!Number.isFinite(value)) return fail('the value must be a number', 2);
83
+ const ev = { ev: 'measured', anchor, value };
84
+ if (flags.target !== undefined) {
85
+ const t = num(flags.target);
86
+ if (!Number.isFinite(t)) return fail('--target must be a number', 2);
87
+ ev.target = t;
88
+ }
89
+ if (flags.unit !== undefined) ev.unit = flags.unit;
90
+ ev.as_of = flags['as-of'] || new Date(now == null ? Date.now() : now).toISOString().slice(0, 10);
91
+ if (flags.source !== undefined) ev.source = flags.source;
92
+ const actor = actorLib.fromCli({ isTTY: isTTY === undefined ? !!(process.stdin.isTTY && process.stdout.isTTY) : isTTY, env: env === undefined ? process.env : env });
93
+ const r = measurements.appendFromCli(root, [ev], { actor, now });
94
+ if (!r.ok) return fail(r.error);
95
+ if (json) say(JSON.stringify({ ok: true, written: r.written, eid: r.lines[0].eid }));
96
+ else say(`✓ ${anchor}: ${value}${ev.unit || ''} on ${ev.as_of} recorded (source ${ev.source})`);
97
+ return 0;
98
+ }
99
+
100
+ module.exports = { run, COMMANDS, USAGE };
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+ /* measurements.js — THE ONE WRITER and THE ONE FOLD of measurements.jsonl: a measured value of a KR or a health
3
+ * budget, with its target, its date and its source (pm-os-absorb, PRD R17, R23, R24; D86).
4
+ *
5
+ * readMeasurements(root) → {
6
+ * ok: true,
7
+ * byAnchor: { [anchor]: [{ value, target, unit, as_of, source, by, at }] }, oldest first; `source` is "path@sha"
8
+ * flags: [{ kind, eid, why }],
9
+ * }
10
+ * latest(result, anchor) → the newest measurement of `anchor` by as_of, or null
11
+ * appendFromUi(root, events, { actor, now }) → { ok, written, lines } | { ok: false, status, error, index }
12
+ * appendFromCli(root, events, { actor, now }) → the same
13
+ * checkEvent(ev) → null | error text the caller-side checks, shared with `pmos measure`
14
+ *
15
+ * `anchor` is the initiative's parent_kr as written (a KR id, or `health:<id>`, D59). A measurement needs a source; an
16
+ * agent may write one (it is evidence, not a PM act), so the actor comes from actor.js, never from the caller.
17
+ * The workspace never computes a value (PRD Non-goals): a group shows a value only from a line here.
18
+ *
19
+ * THE `measured` LINE (the shared contract):
20
+ * { v: 1, eid, ev: 'measured', anchor, value: <number>, target: <number> | null, unit?: '%'|…, as_of: 'YYYY-MM-DD',
21
+ * source: '<path>@<sha>', by, via, at }
22
+ * pmOnly(rec) → false: a measurement is evidence, not a PM act.
23
+ *
24
+ * `source` is a repository-relative path, `@`, and a commit sha of 7 to 40 hex characters: the file the number was
25
+ * read from, at the commit it was read at. The writer does not open it (no read outside the call's own checks), so a
26
+ * wrong path is the writer's caller's error; scripts/check-measurements.js reports a path that is not in the tree.
27
+ *
28
+ * The fold is order-free, like lib/plan-items.js: byte-identical duplicates count once, one eid with two contents keeps
29
+ * the first. The ledger is one per repository: lib/plan-ledgers.js ledgerPath(root, 'measurements'). Nothing is cached.
30
+ */
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+ const crypto = require('crypto');
34
+ const ledgers = require('./plan-ledgers.js');
35
+ const actorLib = require('./actor.js');
36
+ const { createSinkGuard } = require('./sink.js');
37
+
38
+ const MAX_LINES = 50;
39
+ const EID_RE = /^[0-9a-z]{12}$/;
40
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
41
+ const AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
42
+ /** A KR id as the OKR files write it, or a health budget or a domain (D59, D67). One line, no control characters. */
43
+ const ANCHOR_RE = /^(?:health:[a-z0-9][a-z0-9-]{0,59}|domain:[a-z0-9][a-z0-9-]{0,59}|[A-Za-z0-9][A-Za-z0-9 .:_-]{0,79})$/;
44
+ const SOURCE_RE = /^([A-Za-z0-9_][A-Za-z0-9_./-]{0,239})@([0-9a-f]{7,40})$/;
45
+ const UNIT_RE = /^[^\s\x00-\x1f]{1,12}$/;
46
+ const WRITER_SET = Object.freeze(['v', 'eid', 'by', 'via', 'at', 'override', 'pm', 'actor', 'nonce']);
47
+ const CALLER_KEYS = Object.freeze(['anchor', 'value', 'target', 'unit', 'as_of', 'source']);
48
+ const STORED_KEYS = Object.freeze(['v', 'eid', 'ev', 'by', 'via', 'at', ...CALLER_KEYS]);
49
+ const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
50
+
51
+ const isStr = (v) => typeof v === 'string';
52
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
53
+ const dateOk = (v) => isStr(v) && DATE_RE.test(v) && !Number.isNaN(Date.parse(v + 'T00:00:00Z'))
54
+ && new Date(v + 'T00:00:00Z').toISOString().slice(0, 10) === v;
55
+
56
+ function newEventId() {
57
+ let out = '';
58
+ while (out.length < 12) {
59
+ for (const b of crypto.randomBytes(24)) {
60
+ if (b >= 252) continue;
61
+ out += ALPHABET[b % 36];
62
+ if (out.length === 12) break;
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /** The source as { path, sha }, or null when it is not "<repository-relative path>@<sha>". */
69
+ function parseSource(s) {
70
+ const m = isStr(s) ? SOURCE_RE.exec(s) : null;
71
+ if (!m) return null;
72
+ if (m[1].split('/').some((seg) => seg === '..' || seg === '.' || seg === '')) return null;
73
+ return { path: m[1], sha: m[2] };
74
+ }
75
+
76
+ /** The checks on what a caller sends (the writer runs them; `pmos measure` runs them before it writes). */
77
+ function checkEvent(ev) {
78
+ if (!ev || typeof ev !== 'object' || Array.isArray(ev)) return 'each line must be a JSON object';
79
+ const sent = WRITER_SET.find((k) => has(ev, k));
80
+ if (sent) return `the writer sets "${sent}"; do not send it`;
81
+ if (ev.ev !== 'measured') return 'ev must be measured';
82
+ const extra = Object.keys(ev).filter((k) => k !== 'ev' && !CALLER_KEYS.includes(k));
83
+ if (extra.length) return `a measured line has no field ${extra.join(', ')}`;
84
+ if (!isStr(ev.anchor) || !ANCHOR_RE.test(ev.anchor.trim()) || ev.anchor !== ev.anchor.trim()) return 'anchor must be a KR id, health:<id> or domain:<id>';
85
+ if (typeof ev.value !== 'number' || !Number.isFinite(ev.value)) return 'value must be a number';
86
+ if (has(ev, 'target') && ev.target !== null && (typeof ev.target !== 'number' || !Number.isFinite(ev.target))) return 'target must be a number, or null';
87
+ if (has(ev, 'unit') && !(isStr(ev.unit) && UNIT_RE.test(ev.unit))) return 'unit must be a short word with no spaces, like %';
88
+ if (!dateOk(ev.as_of)) return 'as_of must be YYYY-MM-DD';
89
+ if (!has(ev, 'source') || ev.source === '' || ev.source == null) return 'a measurement needs a source: <path>@<commit sha>';
90
+ if (!parseSource(ev.source)) return 'source must be <repository-relative path>@<commit sha of 7 to 40 hex characters>';
91
+ return null;
92
+ }
93
+
94
+ /** Why a stored line is not a valid line of this ledger, or null. */
95
+ function lineErr(r) {
96
+ if (r.v !== 1) return 'v must be 1';
97
+ if (!isStr(r.eid) || !EID_RE.test(r.eid)) return 'eid must be 12 characters of [0-9a-z]';
98
+ if (!isStr(r.by) || !actorLib.BY_RE.test(r.by)) return 'by must be pm:<slug>, agent:unknown or agent:<session>';
99
+ if (!actorLib.VIA.includes(r.via)) return 'via must be ui or cli';
100
+ if (!isStr(r.at) || !AT_RE.test(r.at)) return 'at must be an ISO UTC time';
101
+ const extra = Object.keys(r).filter((k) => !STORED_KEYS.includes(k));
102
+ if (extra.length) return `a measured line has no field ${extra.join(', ')}`;
103
+ const body = {};
104
+ for (const k of ['ev', ...CALLER_KEYS]) if (has(r, k)) body[k] = r[k];
105
+ return checkEvent(body);
106
+ }
107
+
108
+ function readMeasurements(root) {
109
+ const out = { ok: true, byAnchor: {}, flags: [] };
110
+ const { lines, bad } = ledgers.readLines(root, 'measurements');
111
+ for (const b of bad) out.flags.push({ kind: 'unreadable', eid: null, why: `line ${b.n}: ${b.why}` });
112
+ const kept = [];
113
+ for (const { n, rec } of lines) {
114
+ if (rec.ev !== 'measured') { out.flags.push({ kind: 'invalid', eid: isStr(rec.eid) ? rec.eid : null, why: `line ${n}: ev must be measured` }); continue; }
115
+ const why = lineErr(rec);
116
+ if (why) { out.flags.push({ kind: 'invalid', eid: isStr(rec.eid) ? rec.eid : null, why: `line ${n}: ${why}` }); continue; }
117
+ kept.push({ n, rec });
118
+ }
119
+ kept.sort((a, b) => (Date.parse(a.rec.at) - Date.parse(b.rec.at)) || (a.rec.eid < b.rec.eid ? -1 : a.rec.eid > b.rec.eid ? 1 : 0) || (a.n - b.n));
120
+ const seen = new Map();
121
+ for (const { n, rec } of kept) {
122
+ const text = JSON.stringify(rec);
123
+ if (seen.has(rec.eid)) {
124
+ if (seen.get(rec.eid) !== text) out.flags.push({ kind: 'duplicate-event', eid: rec.eid, why: `line ${n}: the eid ${rec.eid} is used by two different lines; the first one counts` });
125
+ continue;
126
+ }
127
+ seen.set(rec.eid, text);
128
+ (out.byAnchor[rec.anchor] = out.byAnchor[rec.anchor] || []).push({
129
+ value: rec.value, target: Number.isFinite(rec.target) ? rec.target : null, unit: rec.unit || null,
130
+ as_of: rec.as_of, source: rec.source, by: rec.by, at: rec.at,
131
+ });
132
+ }
133
+ for (const rows of Object.values(out.byAnchor)) rows.sort((a, b) => String(a.as_of).localeCompare(String(b.as_of)) || String(a.at).localeCompare(String(b.at)));
134
+ return out;
135
+ }
136
+
137
+ const pmOnly = () => false;
138
+
139
+ function latest(result, anchor) {
140
+ const rows = result && result.byAnchor && Object.prototype.hasOwnProperty.call(result.byAnchor, anchor) ? result.byAnchor[anchor] : null;
141
+ if (!Array.isArray(rows) || !rows.length) return null;
142
+ return rows.reduce((a, b) => (String(b.as_of) >= String(a.as_of) ? b : a));
143
+ }
144
+
145
+ const refuse = (status, error, index) => ({ ok: false, status, error, index: index == null ? null : index });
146
+
147
+ function append(root, events, actor, { now } = {}) {
148
+ if (!actorLib.valid(actor)) return refuse(500, 'measurements: the caller must pass the actor from pm-guard.js or actor.js', null);
149
+ if (!Array.isArray(events) || !events.length) return refuse(400, 'send at least one line', null);
150
+ if (events.length > MAX_LINES) return refuse(413, `send at most ${MAX_LINES} lines at once`, null);
151
+ for (let i = 0; i < events.length; i++) {
152
+ const why = checkEvent(events[i]);
153
+ if (why) return refuse(400, why, i);
154
+ }
155
+ const read = ledgers.readLines(root, 'measurements');
156
+ if (read.bad.some((b) => b.n === 0)) return refuse(500, 'could not read the measurements ledger — nothing was saved: ' + read.bad[0].why.slice(0, 160), null);
157
+ const clock = now == null ? Date.now() : now;
158
+ let last = 0;
159
+ for (const l of read.lines) {
160
+ const t = isStr(l.rec.at) ? Date.parse(l.rec.at) : NaN;
161
+ if (Number.isFinite(t) && t <= clock + 1000 && t > last) last = t;
162
+ }
163
+ const recs = events.map((ev) => {
164
+ last = Math.max(clock, last + 1);
165
+ const rec = { v: 1, eid: newEventId(), ev: 'measured', anchor: ev.anchor, value: ev.value, target: has(ev, 'target') ? ev.target : null };
166
+ if (has(ev, 'unit')) rec.unit = ev.unit;
167
+ Object.assign(rec, { as_of: ev.as_of, source: ev.source, by: actor.by, via: actor.via, at: new Date(last).toISOString() });
168
+ return rec;
169
+ });
170
+ let file;
171
+ try { file = ledgers.ledgerPath(root, 'measurements'); fs.mkdirSync(path.dirname(file), { recursive: true }); }
172
+ catch { return refuse(500, 'could not record — nothing was saved', null); }
173
+ const guard = createSinkGuard({ sink: file, what: 'measurements' });
174
+ let wrote = 0;
175
+ for (const rec of recs) {
176
+ try { guard.appendLine(file, rec); wrote++; }
177
+ catch {
178
+ return refuse(500, wrote ? `could not record — ${wrote} of ${recs.length} lines were saved before the write failed` : 'could not record — nothing was saved', wrote);
179
+ }
180
+ }
181
+ return { ok: true, written: recs.length, lines: recs.map((r) => ({ eid: r.eid, anchor: r.anchor })) };
182
+ }
183
+
184
+ /** A page press (requirePagePress's actor). */
185
+ function appendFromUi(root, events, { actor, now } = {}) { return append(root, events, actor, { now }); }
186
+ /** `pmos measure` (actor.fromCli's actor; an agent may write a measurement). */
187
+ function appendFromCli(root, events, { actor, now } = {}) { return append(root, events, actor, { now }); }
188
+
189
+ module.exports = { MAX_LINES, readMeasurements, latest, pmOnly, checkEvent, parseSource, lineErr, appendFromUi, appendFromCli };