create-pmos 0.133.0 → 0.135.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/bin/notes-replay.js +235 -0
  7. package/workspace/c3-briefing-test.js +130 -0
  8. package/workspace/c3-foundation-test.js +159 -0
  9. package/workspace/c3-ib-items-test.js +105 -0
  10. package/workspace/c3-ib-more-test.js +120 -0
  11. package/workspace/c3-ib-triage-backlog-test.js +119 -0
  12. package/workspace/c3-ini-harness.js +66 -0
  13. package/workspace/c3-initiatives-test.js +140 -0
  14. package/workspace/c3-one-home-test.js +14 -1
  15. package/workspace/c3-reports-test.js +57 -18
  16. package/workspace/c3-settings-test.js +139 -0
  17. package/workspace/c3-work-test.js +42 -10
  18. package/workspace/fixtures/one-home-baseline.json +1 -9
  19. package/workspace/guide/briefing-cards.md +15 -3
  20. package/workspace/guide/briefing.md +12 -8
  21. package/workspace/guide/calendar-popup.md +2 -2
  22. package/workspace/guide/console-calendar.md +39 -10
  23. package/workspace/guide/index.md +1 -0
  24. package/workspace/guide/initiative-boards.md +65 -0
  25. package/workspace/guide/jira.md +3 -1
  26. package/workspace/guide/layout.md +2 -0
  27. package/workspace/guide/my-work.md +5 -3
  28. package/workspace/guide/notes.md +35 -2
  29. package/workspace/guide/reports-dashboards.md +29 -5
  30. package/workspace/guide/settings.md +68 -0
  31. package/workspace/guide/welcome.md +10 -10
  32. package/workspace/lib/calendar-cli.js +3 -2
  33. package/workspace/lib/calendar-layers.js +21 -10
  34. package/workspace/lib/calendar-tidy.js +230 -0
  35. package/workspace/lib/engine/boards.js +542 -0
  36. package/workspace/lib/engine/breakdown-ops.js +180 -0
  37. package/workspace/lib/engine/calendar-import.js +84 -0
  38. package/workspace/lib/engine/calendar-model.js +185 -0
  39. package/workspace/lib/engine/card-state.js +92 -0
  40. package/workspace/lib/engine/derived/derive.js +92 -0
  41. package/workspace/lib/engine/derived/plan.js +3 -0
  42. package/workspace/lib/engine/derived/trust.js +3 -0
  43. package/workspace/lib/engine/doc-health.js +142 -0
  44. package/workspace/lib/engine/doc-sheet.js +75 -0
  45. package/workspace/lib/engine/intake.js +78 -0
  46. package/workspace/lib/engine/outbox.js +86 -0
  47. package/workspace/lib/engine/plan-forecast.js +342 -0
  48. package/workspace/lib/engine/promises.js +29 -0
  49. package/workspace/lib/engine/records/index.js +114 -0
  50. package/workspace/lib/engine/releases.js +96 -0
  51. package/workspace/lib/engine/replies.js +99 -0
  52. package/workspace/lib/engine/reply-contract/reply-contract.js +372 -0
  53. package/workspace/lib/engine/report-families.js +175 -0
  54. package/workspace/lib/engine/roadmap-ops.js +150 -0
  55. package/workspace/lib/engine/sessions-list.js +27 -0
  56. package/workspace/lib/engine/signal-rules.js +95 -0
  57. package/workspace/lib/engine/signals/cards.js +57 -0
  58. package/workspace/lib/engine/signals/detectors.js +370 -0
  59. package/workspace/lib/engine/signals/facts-cli.js +8 -0
  60. package/workspace/lib/engine/signals/facts.js +197 -0
  61. package/workspace/lib/engine/signals/index.js +33 -0
  62. package/workspace/lib/engine/signals/kinds.js +110 -0
  63. package/workspace/lib/engine/signals/layout-paths.js +44 -0
  64. package/workspace/lib/engine/signals/options.js +24 -0
  65. package/workspace/lib/engine/signals/score.js +72 -0
  66. package/workspace/lib/engine/signals/select.js +47 -0
  67. package/workspace/lib/engine/step-planner.js +65 -0
  68. package/workspace/lib/engine/trust/claim-rules.json +93 -0
  69. package/workspace/lib/engine/trust/import-calendar.js +21 -0
  70. package/workspace/lib/engine/trust/import-guard.js +36 -0
  71. package/workspace/lib/engine/trust/ledger.js +53 -0
  72. package/workspace/lib/engine/trust/outbox.js +45 -0
  73. package/workspace/lib/engine/trust/plan.js +5 -0
  74. package/workspace/lib/engine/trust/trust.js +21 -0
  75. package/workspace/lib/engine/truth-table.js +57 -0
  76. package/workspace/lib/engine/workspace-lib.js +24 -0
  77. package/workspace/lib/factory-join.js +1 -1
  78. package/workspace/lib/facts-async.js +4 -4
  79. package/workspace/lib/headless.js +95 -0
  80. package/workspace/lib/import-overlay.js +2 -1
  81. package/workspace/lib/jira-status.js +30 -3
  82. package/workspace/lib/notes-audio.js +28 -9
  83. package/workspace/lib/notes-capture.js +136 -0
  84. package/workspace/lib/notes-final.js +38 -13
  85. package/workspace/lib/notes-live-guard.js +117 -0
  86. package/workspace/lib/notes-replay.js +125 -0
  87. package/workspace/lib/notes-screen.js +180 -0
  88. package/workspace/lib/notes-whisper.js +139 -16
  89. package/workspace/lib/notes.js +4 -2
  90. package/workspace/lib/outbox-store.js +10 -6
  91. package/workspace/lib/pmos-verbs.js +3 -1
  92. package/workspace/lib/proactive-cli.js +7 -0
  93. package/workspace/lib/proactive.js +15 -2
  94. package/workspace/lib/repo-script.js +36 -0
  95. package/workspace/lib/report-dashboards.js +5 -2
  96. package/workspace/lib/report-frame.js +122 -0
  97. package/workspace/lib/report-schedules.js +113 -0
  98. package/workspace/lib/route-modules.js +9 -2
  99. package/workspace/lib/routes-boards.js +734 -0
  100. package/workspace/lib/routes-brief-page.js +13 -4
  101. package/workspace/lib/routes-briefing.js +22 -11
  102. package/workspace/lib/routes-c3-init.js +4 -3
  103. package/workspace/lib/routes-c3-life.js +2 -1
  104. package/workspace/lib/routes-c3-reports.js +96 -13
  105. package/workspace/lib/routes-c3-strategy.js +4 -3
  106. package/workspace/lib/routes-c3-timeline.js +3 -2
  107. package/workspace/lib/routes-calendar-import.js +3 -2
  108. package/workspace/lib/routes-calendar-views.js +9 -4
  109. package/workspace/lib/routes-doc-health.js +2 -1
  110. package/workspace/lib/routes-doc-sheet.js +2 -1
  111. package/workspace/lib/routes-forecast.js +2 -1
  112. package/workspace/lib/routes-hooks.js +3 -2
  113. package/workspace/lib/routes-initiatives.js +2 -1
  114. package/workspace/lib/routes-intake.js +2 -1
  115. package/workspace/lib/routes-notes.js +113 -15
  116. package/workspace/lib/routes-outbox.js +23 -3
  117. package/workspace/lib/routes-pm-items.js +19 -6
  118. package/workspace/lib/routes-records.js +3 -2
  119. package/workspace/lib/routes-releases.js +2 -1
  120. package/workspace/lib/routes-report-facts.js +4 -3
  121. package/workspace/lib/routes-report-families.js +2 -1
  122. package/workspace/lib/routes-roadmap-ops.js +5 -4
  123. package/workspace/lib/routes-runs.js +6 -5
  124. package/workspace/lib/routes-settings.js +51 -0
  125. package/workspace/lib/routes-signals.js +3 -2
  126. package/workspace/lib/routes-stage-tools.js +220 -0
  127. package/workspace/lib/run-answers.js +2 -1
  128. package/workspace/lib/settings-store.js +226 -0
  129. package/workspace/lib/stage-tools.js +208 -0
  130. package/workspace/lib/vendor/scripts/signals/layout-paths.js +2 -1
  131. package/workspace/lib/vendor/scripts/workspace-lib.js +24 -0
  132. package/workspace/lib/work-evidence.js +73 -0
  133. package/workspace/package-lock.json +2 -2
  134. package/workspace/package.json +1 -1
  135. package/workspace/public/pm/briefing-deck.js +63 -17
  136. package/workspace/public/pm/briefing-model.js +36 -7
  137. package/workspace/public/pm/c3-ambient.js +100 -0
  138. package/workspace/public/pm/c3-brief-needs.js +26 -6
  139. package/workspace/public/pm/c3-brief-pulse.js +12 -4
  140. package/workspace/public/pm/c3-brief-today.js +56 -21
  141. package/workspace/public/pm/c3-brief.js +2 -1
  142. package/workspace/public/pm/c3-calendar.js +161 -42
  143. package/workspace/public/pm/c3-calpop.js +5 -5
  144. package/workspace/public/pm/c3-demo-boards.js +649 -0
  145. package/workspace/public/pm/c3-demo-brief-page.js +1 -1
  146. package/workspace/public/pm/c3-demo-cal4.js +3 -2
  147. package/workspace/public/pm/c3-demo-cal5.js +52 -0
  148. package/workspace/public/pm/c3-demo-data.js +17 -27
  149. package/workspace/public/pm/c3-demo-rep3.js +46 -5
  150. package/workspace/public/pm/c3-demo.js +2 -1
  151. package/workspace/public/pm/c3-dock.js +1 -1
  152. package/workspace/public/pm/c3-ib-docs.js +80 -0
  153. package/workspace/public/pm/c3-ib-initiatives.js +134 -0
  154. package/workspace/public/pm/c3-ib-items.js +201 -0
  155. package/workspace/public/pm/c3-ib-releases.js +51 -0
  156. package/workspace/public/pm/c3-ib-sprints.js +55 -0
  157. package/workspace/public/pm/c3-ib-triage.js +178 -0
  158. package/workspace/public/pm/c3-ii-tabs.js +240 -0
  159. package/workspace/public/pm/c3-ini-records.js +197 -0
  160. package/workspace/public/pm/c3-ini-timeline.js +278 -0
  161. package/workspace/public/pm/c3-ini-ui.js +229 -0
  162. package/workspace/public/pm/c3-ini-work.js +139 -0
  163. package/workspace/public/pm/c3-initiatives.css +218 -0
  164. package/workspace/public/pm/c3-initiatives.js +741 -0
  165. package/workspace/public/pm/c3-plan.js +2 -2
  166. package/workspace/public/pm/c3-reports.css +47 -47
  167. package/workspace/public/pm/c3-reports.js +223 -185
  168. package/workspace/public/pm/c3-settings.css +74 -0
  169. package/workspace/public/pm/c3-settings.js +703 -0
  170. package/workspace/public/pm/c3-shell.js +102 -36
  171. package/workspace/public/pm/c3-terminal-icons.js +2 -2
  172. package/workspace/public/pm/c3-terminal.js +2 -2
  173. package/workspace/public/pm/calendar-views.js +26 -13
  174. package/workspace/public/pm/components.css +319 -213
  175. package/workspace/public/pm/console3.css +118 -23
  176. package/workspace/public/pm/dest-notes.js +26 -4
  177. package/workspace/public/pm/notes-screen-capture.js +165 -0
  178. package/workspace/public/pm/notes-screen-view.js +98 -0
  179. package/workspace/public/pm/panel-trees.js +5 -2
  180. package/workspace/public/pm/pm-work-view.js +233 -146
  181. package/workspace/public/pm/shell-bell.js +3 -2
  182. package/workspace/public/pm/shell-recording.js +148 -17
  183. package/workspace/public/pm/tokens.css +2 -2
  184. package/workspace/public/pm/view-notes.js +53 -11
  185. package/workspace/public/pm/welcome.js +197 -89
  186. package/workspace/public/welcome.html +6 -4
  187. package/workspace/server.js +105 -6
  188. package/workspace/skills/day-check.skill +1 -1
  189. package/workspace/skills/jira-sync.skill +6 -4
  190. package/workspace/skills/morning-brief.skill +1 -1
  191. package/workspace/test-files.json +14 -3
  192. package/workspace/test-lists/c3-ib-items.json +9 -0
  193. package/workspace/test-lists/c3-ib-more.json +9 -0
  194. package/workspace/test-lists/c3-ib-triage-backlog.json +9 -0
  195. package/workspace/test-lists/c3-initiative-boards.json +6 -0
  196. package/workspace/test-lists/calendar-tidy-up.json +6 -0
  197. package/workspace/test-lists/console3-briefing.json +9 -0
  198. package/workspace/test-lists/console3-foundation.json +9 -0
  199. package/workspace/test-lists/console3-reports.json +3 -3
  200. package/workspace/test-lists/meeting-notes.json +12 -4
  201. package/workspace/test-lists/workspace-headless-jobs.json +5 -0
  202. package/workspace/test-lists/workspace-package-script-closure.json +6 -0
  203. package/workspace/lib/vendor/workspace/lib/actor.js +0 -91
  204. package/workspace/lib/vendor/workspace/lib/board-cache.js +0 -156
  205. package/workspace/lib/vendor/workspace/lib/console-state.js +0 -295
  206. package/workspace/lib/vendor/workspace/lib/factory-join.js +0 -158
  207. package/workspace/lib/vendor/workspace/lib/git-info.js +0 -306
  208. package/workspace/lib/vendor/workspace/lib/plan-items.js +0 -922
  209. package/workspace/lib/vendor/workspace/lib/plan-tree.js +0 -419
  210. package/workspace/lib/vendor/workspace/lib/review-gate.js +0 -39
  211. package/workspace/lib/vendor/workspace/lib/sink.js +0 -321
  212. package/workspace/lib/vendor/workspace/lib/state-root.js +0 -587
@@ -1,922 +0,0 @@
1
- 'use strict';
2
- /* plan-items.js — THE ONE WRITER and THE ONE FOLD of an initiative's breakdown (pm-os-absorb Run 1, PRD R24).
3
- *
4
- * The ledger is `plan-items.jsonl` in the repository's runs directory (`planning/runs/` for PMOS-self,
5
- * `pmos/state/` in a kit repository). It is control graph (pmos/graph-membership.json): Keep is the PM's
6
- * disposition of an agent batch, and an item's state feeds the PM's view of a gate.
7
- *
8
- * THREE CALLERS, AND NO OTHER: the workspace route (server.js), `pmos item` (lib/item-cli.js) and the
9
- * read-only checker (scripts/check-plan-items.js). Agents reach this module only through `pmos item`. Every
10
- * require of this file is spelled with `.js` so scripts/check-separation.js lists each caller by name.
11
- *
12
- * ATTRIBUTION IS SET BY THE CALL PATH, NEVER BY A FIELD. `appendFromUi` writes `pm:wawan` / `ui`;
13
- * `appendFromCli` writes `pm:wawan` only at an interactive terminal, `agent:<PMOS_SESSION>` from a bound
14
- * session, and `agent:unknown` otherwise. A caller that sends `by`, `via`, `at`, `eid`, `v` or `override`
15
- * is refused — accepting one, even an identical one, would make the field something a caller chooses.
16
- *
17
- * WHO IS WHO COMES FROM lib/actor.js (Run 2, slice 2A). The server route runs lib/pm-guard.js first (Origin, the
18
- * per-launch nonce, writer fields; act `keep` when the call carries a Keep) and passes the guard's actor on;
19
- * `pmos item` takes actor.fromCli. R23 (X35): an agent does not change the PM's items. A set or a flag by an agent on
20
- * an item whose owner is `you` is refused with 403, and so is a withdraw of an item it did not plan; the agent asks the
21
- * PM instead. A move of one of the PM's items is open only to the session the PM handed that item to (Hand to agent:
22
- * the session meta's `plan_item`, read through actor.sessionForToken), so it can take it through Doing to Done with
23
- * evidence (R23 "finishes"). Item-change proposals are not built (deferred, named in the run PR).
24
- *
25
- * HONEST LIMIT (rubric anti-optimism). A process that runs as the PM's own user can still read the nonce from the
26
- * page, or fake a terminal with no PMOS_SESSION, and be recorded as the PM. The fold flags a `kept` line whose `via`
27
- * is not `ui`; it cannot tell a forged `ui` line from a real one. `agent:unknown` is one shared identity, so two
28
- * unbound processes can withdraw each other's items, and a withdraw line appended by hand is not flagged.
29
- *
30
- * NOTHING IS CACHED (D33). Every read folds the whole ledger again; the ledger is small and append-only.
31
- */
32
- const fs = require('fs');
33
- const path = require('path');
34
- const crypto = require('crypto');
35
- const { createSinkGuard } = require('./sink.js');
36
- const planTree = require('./plan-tree.js');
37
- const consoleState = require('./console-state');
38
- const actorLib = require('./actor.js');
39
- const stateRoot = require('./state-root');
40
-
41
- const LEDGER = 'plan-items.jsonl';
42
- const EVENTS = ['planned', 'set', 'moved', 'flagged', 'withdrawn', 'kept'];
43
- const STATES = ['todo', 'doing', 'review', 'done'];
44
- const PM_SLUG = 'wawan';
45
- const ID_RE = /^(.+)\.([0-9a-z]{10})$/;
46
- const INIT_RE = planTree.INIT_RE;
47
- const SESSION_RE = /^sess-\d+\.[0-9a-f]{8}$/;
48
- const EID_RE = /^[0-9a-z]{12}$/;
49
- const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
50
- const AT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
51
- const BY_RE = /^(?:pm:[a-z0-9_-]{1,40}|agent:unknown|agent:sess-\d+\.[0-9a-f]{8})$/;
52
- const MAX_LINES = 50;
53
- const SET_FIELDS = ['title', 'stage', 'order', 'owner', 'after', 'start', 'due', 'milestone', 'note'];
54
- const WRITER_FIELDS = ['v', 'eid', 'by', 'via', 'at', 'override'];
55
- const EVIDENCE_KINDS = ['commit', 'pr', 'file', 'session', 'note'];
56
- const STATE_LABEL = { todo: 'Todo', doing: 'Doing', review: 'Review', done: 'Done' };
57
- const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
58
-
59
- // ── paths ─────────────────────────────────────────────────────────────────────────────────────────
60
- /** One ledger per repository, whichever worktree writes: the main working tree of `root`. */
61
- function ledgerRoot(root) { return planTree.mainTree(path.resolve(root)); }
62
- function engine() {
63
- const loaded = consoleState.loadEngine();
64
- if (!loaded.ok) throw new Error(loaded.error);
65
- return loaded.engine;
66
- }
67
- function ledgerPath(root) { return path.join(engine().layout(ledgerRoot(root)).runsDir, LEDGER); }
68
-
69
- // ── ids ───────────────────────────────────────────────────────────────────────────────────────────
70
- /** n characters of [0-9a-z] from crypto.randomBytes, by rejection so every character is equally likely. */
71
- function randomChars(n) {
72
- let out = '';
73
- while (out.length < n) {
74
- for (const b of crypto.randomBytes(n * 2)) {
75
- if (b >= 252) continue; // 252 = 7 × 36: keep the modulo unbiased
76
- out += ALPHABET[b % 36];
77
- if (out.length === n) break;
78
- }
79
- }
80
- return out;
81
- }
82
- function newEventId() { return randomChars(12); }
83
- /** `<initiative>.<10 × [0-9a-z]>`, retried until it is unused in `taken` (a Set or a predicate). */
84
- function newItemId(initiative, taken) {
85
- const used = typeof taken === 'function' ? taken : (id) => !!(taken && taken.has && taken.has(id));
86
- for (;;) {
87
- const id = `${initiative}.${randomChars(10)}`;
88
- if (!used(id)) return id;
89
- }
90
- }
91
- /** The initiative an item id belongs to: everything before the LAST dot (initiative ids may hold dots). */
92
- function initiativeOf(id) { const m = ID_RE.exec(String(id || '')); return m ? m[1] : null; }
93
-
94
- // ── labels ────────────────────────────────────────────────────────────────────────────────────────
95
- const label = planTree.byLabel;
96
-
97
- // ── what the page is sent: an agent's actor without its session token ─────────────────────────────────
98
- /* An agent writes as `agent:<PMOS_SESSION>`, and the token is `sess-<n>.<8 hex>`. The ledger keeps the token whole: it is
99
- * the attribution, the file is committed, and the writer and the checker match on it. The routes the page reads (the
100
- * Breakdown layout through readLayout, the needs-you rows through needsFor) send `agent:sess-<n>`, which is all the page
101
- * shows ("agent · sess-3"). A batch is named the same way, with `#2`, `#3` on a later batch whose actor reads the same on
102
- * one initiative (another machine, or a server whose saved sessions are gone, counts from sess-0 again), so each card
103
- * names one batch. Keep sends that name back, and the writer resolves it from the items (buildRecord, `kept`). */
104
- const PAGE_ACTOR_RE = /^(agent:sess-\d+)\.[0-9a-f]{8}$/;
105
- const TOKEN_TEXT_RE = /\b(sess-\d+)\.[0-9a-f]{8}\b/g;
106
- const PAGE_BATCH_RE = /^(agent:sess-\d+)(?:#\d+)?$/;
107
- const actorForPage = (by) => (isStr(by) ? by.replace(PAGE_ACTOR_RE, '$1') : by);
108
- const textForPage = (t) => (isStr(t) ? t.replace(TOKEN_TEXT_RE, '$1') : t);
109
- /** The page's name of each batch, in the batches' order. `scope(b)` is what two names must share to be told apart. */
110
- function batchNames(batches, scope) {
111
- const seen = new Map();
112
- return (batches || []).map((b) => {
113
- const shown = actorForPage(b.by);
114
- const k = scope(b) + '\u0000' + shown;
115
- const n = (seen.get(k) || 0) + 1;
116
- seen.set(k, n);
117
- return n === 1 ? shown : `${shown}#${n}`;
118
- });
119
- }
120
- /** A Breakdown layout (plan-tree.js) as the page is sent it: the same keys, and no session token in any actor or flag. */
121
- function layoutForPage(lay) {
122
- const items = {};
123
- for (const [id, it] of Object.entries(lay.items || {})) {
124
- items[id] = {
125
- ...it,
126
- createdBy: actorForPage(it.createdBy), updatedBy: actorForPage(it.updatedBy), movedBy: actorForPage(it.movedBy),
127
- blocked: it.blocked ? { ...it.blocked, by: actorForPage(it.blocked.by) } : it.blocked,
128
- history: (it.history || []).map((h) => ({ ...h, by: actorForPage(h.by) })),
129
- };
130
- }
131
- const names = batchNames(lay.batches, () => ''); // a layout is one initiative's
132
- return {
133
- ...lay, items,
134
- batches: (lay.batches || []).map((b, i) => ({ ...b, key: names[i], by: names[i] })),
135
- flags: (lay.flags || []).map((f) => ({ ...f, message: textForPage(f.message) })),
136
- };
137
- }
138
-
139
- // ── field validators (shared by the fold's schema check and the writer's input check) ────────────────
140
- const isStr = (v) => typeof v === 'string';
141
- const oneLine = (v, max) => isStr(v) && v.trim().length >= 1 && v.trim().length <= max && !/[\r\n]/.test(v);
142
- function ownerOk(v) {
143
- if (v === 'you' || v === 'agent') return true;
144
- const m = /^(person|tracker):(.+)$/s.exec(String(v));
145
- return !!m && m[2].length >= 1 && m[2].length <= 60 && !/[\r\n]/.test(m[2]);
146
- }
147
- /* A real calendar day: V8 reads 2026-02-30 as 2 March, so the parsed day must print back as the same string. */
148
- const dateOk = (v) => v === null || (isStr(v) && DATE_RE.test(v) && (() => {
149
- const t = Date.parse(v + 'T00:00:00Z');
150
- return Number.isFinite(t) && new Date(t).toISOString().slice(0, 10) === v;
151
- })());
152
- function evidenceErr(e) {
153
- if (!e || typeof e !== 'object' || Array.isArray(e)) return 'evidence must be {kind, ref}';
154
- if (!EVIDENCE_KINDS.includes(e.kind)) return `evidence kind must be one of ${EVIDENCE_KINDS.join(', ')}`;
155
- const ref = e.ref;
156
- if (!isStr(ref)) return 'evidence ref must be text';
157
- if (e.kind === 'commit' && !/^[0-9a-f]{7,40}$/i.test(ref)) return 'a commit reference is 7 to 40 hex characters';
158
- if (e.kind === 'pr' && !/^#?\d+$/.test(ref)) return 'a pull request reference is a number, for example #412';
159
- if (e.kind === 'file' && (!ref || ref.length > 300 || path.isAbsolute(ref) || ref.split(/[\\/]/).includes('..') || /[\r\n]/.test(ref))) return 'a file reference is a repository-relative path with no ".."';
160
- if (e.kind === 'session' && !/^sess-\d+$/.test(ref)) return 'a session reference is sess-<n>';
161
- if (e.kind === 'note' && !oneLine(ref, 200)) return 'an evidence note is 1 to 200 characters on one line';
162
- return null;
163
- }
164
- /** One field value of `planned`/`set`, checked in isolation. Returns an error sentence or null. */
165
- function fieldErr(k, v) {
166
- switch (k) {
167
- case 'title': return oneLine(v, 200) ? null : 'a title is 1 to 200 characters on one line';
168
- case 'stage': return isStr(v) && v.trim() ? null : 'a stage is required';
169
- case 'order': return typeof v === 'number' && Number.isFinite(v) ? null : 'order must be a finite number';
170
- case 'owner': return ownerOk(v) ? null : 'owner must be you, agent, person:<name> or tracker:<ref> (name or ref 1 to 60 characters)';
171
- case 'after': return Array.isArray(v) && v.every((x) => isStr(x) && ID_RE.test(x)) ? null : 'after must be a list of item ids';
172
- case 'start': case 'due': return dateOk(v) ? null : `${k} must be a date YYYY-MM-DD or null`;
173
- case 'milestone': return typeof v === 'boolean' ? null : 'milestone must be true or false';
174
- case 'note': return v === null || (isStr(v) && v.length <= 1000) ? null : 'a note is at most 1000 characters';
175
- default: return `unknown field ${k}`;
176
- }
177
- }
178
-
179
- /** The §1.2 schema of a STORED line. Returns an error sentence or null. Unknown extra keys are tolerated. */
180
- function lineErr(r) {
181
- if (!r || typeof r !== 'object' || Array.isArray(r)) return 'not a JSON object';
182
- if (r.v !== 1) return 'v is not 1';
183
- if (!isStr(r.eid) || !EID_RE.test(r.eid)) return 'eid is not 12 characters [0-9a-z]';
184
- if (!EVENTS.includes(r.ev)) return `unknown ev ${JSON.stringify(r.ev)}`;
185
- if (!isStr(r.by) || !BY_RE.test(r.by)) return 'by is not pm:<slug>, agent:unknown or agent:<session>';
186
- if (r.via !== 'ui' && r.via !== 'cli') return 'via is not ui or cli';
187
- if (!isStr(r.at) || !AT_RE.test(r.at) || Number.isNaN(Date.parse(r.at))) return 'at is not an ISO 8601 UTC time';
188
- if (!isStr(r.initiative) || !INIT_RE.test(r.initiative)) return 'initiative is not a record name';
189
- if (r.ev !== 'kept') {
190
- if (!isStr(r.item) || !ID_RE.test(r.item)) return 'item is not <initiative>.<10 characters>';
191
- if (initiativeOf(r.item) !== r.initiative) return 'item does not belong to its initiative';
192
- }
193
- switch (r.ev) {
194
- case 'planned': {
195
- for (const k of ['title', 'stage', 'order', 'owner', 'after', 'start', 'due', 'milestone', 'note']) {
196
- const e = fieldErr(k, r[k]);
197
- if (e) return e;
198
- }
199
- return null;
200
- }
201
- case 'set': {
202
- const f = r.fields;
203
- if (!f || typeof f !== 'object' || Array.isArray(f) || !Object.keys(f).length) return 'fields is not a non-empty object';
204
- for (const k of Object.keys(f)) {
205
- if (!SET_FIELDS.includes(k)) return `unknown field ${k}`;
206
- if ((k === 'start' || k === 'due' || k === 'note') && f[k] === null) continue;
207
- const e = fieldErr(k, f[k]);
208
- if (e) return e;
209
- }
210
- return null;
211
- }
212
- case 'moved':
213
- if (!STATES.includes(r.to)) return 'to is not todo, doing, review or done';
214
- if (r.evidence !== undefined && evidenceErr(r.evidence)) return evidenceErr(r.evidence);
215
- if (r.override !== undefined && r.override !== true) return 'override is written only as true';
216
- // The writer computes override for a pm: actor only, so an agent line that carries it was not written by the writer.
217
- if (r.override !== undefined && !String(r.by).startsWith('pm:')) return 'override is written only on a pm: line';
218
- return null;
219
- case 'flagged':
220
- if (r.blocked === true) return oneLine(r.reason, 200) ? null : 'a block reason is 1 to 200 characters';
221
- if (r.blocked === false) return null;
222
- return 'blocked is not true or false';
223
- case 'withdrawn': return null;
224
- case 'kept':
225
- if (r.item !== undefined) return 'a kept line names no item';
226
- if (!isStr(r.batch) || !/^agent:/.test(r.batch)) return 'batch is not agent:<session>';
227
- if (!Array.isArray(r.items) || !r.items.length || !r.items.every((x) => isStr(x) && ID_RE.test(x))) return 'items is not a non-empty list of item ids';
228
- return null;
229
- default: return 'unknown ev';
230
- }
231
- }
232
-
233
- // ── reading ───────────────────────────────────────────────────────────────────────────────────────
234
- /** Every line of the ledger, parsed. An absent file is an empty ledger. A torn or unparseable line is a flag. */
235
- function readLines(root) {
236
- const file = ledgerPath(root);
237
- let st;
238
- try { st = fs.statSync(file); } catch (e) {
239
- if (e && e.code === 'ENOENT') return { lines: [], flags: [] };
240
- throw e;
241
- }
242
- /* A FIFO here would block the read forever; a directory would throw. Neither is an empty ledger. */
243
- if (!st.isFile()) throw new Error(`${file} is not a regular file — refusing to read it as the plan-items ledger`);
244
- return parseText(fs.readFileSync(file, 'utf8'));
245
- }
246
- function parseText(text) {
247
- const lines = []; const flags = [];
248
- const parts = String(text).split('\n');
249
- parts.forEach((raw0, i) => {
250
- const raw = raw0.replace(/\r$/, '');
251
- if (!raw.trim()) return;
252
- const n = i + 1;
253
- let rec;
254
- try { rec = JSON.parse(raw); }
255
- catch {
256
- flags.push({ kind: 'unparseable', line: n, eid: null, item: null, initiative: null, message: `line ${n} is not JSON (a torn or hand-edited line) — it has no effect` });
257
- return;
258
- }
259
- lines.push({ n, raw: raw.trim(), rec });
260
- });
261
- return { lines, flags };
262
- }
263
-
264
- // ── the fold ─────────────────────────────────────────────────────────────────────────────────────
265
- function freshItem(rec) {
266
- return {
267
- id: rec.item, initiative: rec.initiative, title: rec.title.trim(), stage: rec.stage, order: rec.order, owner: rec.owner,
268
- after: rec.after.slice(), start: rec.start, due: rec.due, milestone: rec.milestone, note: rec.note == null ? '' : rec.note,
269
- state: 'todo', blocked: null, evidence: null, noEvidence: false, doneAt: null,
270
- movedBy: null, movedVia: null, override: false, overrideShown: false,
271
- proposed: rec.by.startsWith('agent:'), kept: false,
272
- createdBy: rec.by, createdVia: rec.via, createdAt: rec.at,
273
- updatedBy: rec.by, updatedVia: rec.via, updatedAt: rec.at,
274
- history: [],
275
- };
276
- }
277
- function historyOf(rec) {
278
- const h = { eid: rec.eid, ev: rec.ev, by: rec.by, via: rec.via, at: rec.at };
279
- if (rec.ev === 'planned') h.stage = rec.stage;
280
- if (rec.ev === 'set') h.fields = { ...rec.fields };
281
- if (rec.ev === 'moved') { h.to = rec.to; if (rec.evidence) h.evidence = { ...rec.evidence }; if (rec.override) h.override = true; }
282
- if (rec.ev === 'flagged') { h.blocked = rec.blocked; if (rec.blocked) h.reason = rec.reason; }
283
- return h;
284
- }
285
- const touch = (it, rec) => { it.updatedBy = rec.by; it.updatedVia = rec.via; it.updatedAt = rec.at; it.history.push(historyOf(rec)); };
286
-
287
- /** A mutable fold state, so the writer can apply the events of one call to a working copy as it checks them. */
288
- function createState() {
289
- return { items: new Map(), withdrawn: new Set(), flags: [] };
290
- }
291
- function flag(S, kind, rec, n, message) {
292
- S.flags.push({ kind, line: n == null ? null : n, eid: (rec && rec.eid) || null, item: (rec && rec.item) || null,
293
- initiative: (rec && rec.initiative) || null, message });
294
- }
295
- /** Apply ONE schema-valid line. Every rule of §1.4 step 4 lives here, and only here. */
296
- function applyLine(S, rec, n) {
297
- if (rec.ev === 'planned') {
298
- if (S.items.has(rec.item) || S.withdrawn.has(rec.item)) { flag(S, 'duplicate-id', rec, n, `${rec.item} is planned twice — the later line is ignored`); return; }
299
- const it = freshItem(rec);
300
- it.history.push(historyOf(rec));
301
- S.items.set(rec.item, it);
302
- return;
303
- }
304
- if (rec.ev === 'kept') {
305
- if (rec.via !== 'ui') { flag(S, 'kept-not-ui', rec, n, `a Keep line was written with via "${rec.via}", not from the workspace page — it has no effect`); return; }
306
- if (!rec.by.startsWith('pm:')) { flag(S, 'kept-not-pm', rec, n, `a Keep line was written by ${rec.by}, not the PM — it has no effect`); return; }
307
- for (const id of rec.items) {
308
- const it = S.items.get(id);
309
- if (!it || !it.proposed || it.createdBy !== rec.batch || it.initiative !== rec.initiative) {
310
- S.flags.push({ kind: 'kept-mismatch', line: n == null ? null : n, eid: rec.eid, item: id, initiative: rec.initiative,
311
- message: `${id} is not a proposed item of ${rec.batch} on ${rec.initiative} — this part of the Keep has no effect` });
312
- continue;
313
- }
314
- it.proposed = false; it.kept = true;
315
- touch(it, rec);
316
- }
317
- return;
318
- }
319
- if (S.withdrawn.has(rec.item)) { flag(S, 'after-terminal', rec, n, `${rec.item} was withdrawn — a later ${rec.ev} line has no effect`); return; }
320
- const it = S.items.get(rec.item);
321
- if (!it) { flag(S, 'unknown-item', rec, n, `${rec.ev} names ${rec.item}, which was never planned — it has no effect`); return; }
322
- switch (rec.ev) {
323
- case 'set':
324
- for (const [k, v] of Object.entries(rec.fields)) {
325
- if (k === 'title') it.title = v.trim();
326
- else if (k === 'note') it.note = v == null ? '' : v;
327
- else if (k === 'after') it.after = v.slice();
328
- else it[k] = v;
329
- }
330
- break;
331
- case 'moved': {
332
- /* done → done is the one same-state move: it attaches evidence to an item that reads Done without it. It is
333
- * not a move, so the Done's time, its mover and its override stay as they were. */
334
- const attach = rec.to === 'done' && it.state === 'done';
335
- if (!attach) {
336
- it.doneAt = rec.to === 'done' ? rec.at : null;
337
- it.state = rec.to;
338
- it.movedBy = rec.by; it.movedVia = rec.via;
339
- it.override = !!rec.override; it.overrideShown = !!rec.override;
340
- }
341
- it.evidence = rec.evidence ? { ...rec.evidence } : (rec.to === 'done' ? it.evidence : null);
342
- // From the evidence the item ENDS UP with: evidence given on the way to Review still counts at Done.
343
- it.noEvidence = it.state === 'done' && String(it.movedBy).startsWith('agent:') && !it.evidence;
344
- break;
345
- }
346
- case 'flagged':
347
- it.blocked = rec.blocked ? { reason: rec.reason.trim(), by: rec.by, at: rec.at } : null;
348
- break;
349
- case 'withdrawn':
350
- it.state = 'withdrawn';
351
- S.items.delete(rec.item);
352
- S.withdrawn.add(rec.item);
353
- touch(it, rec);
354
- return;
355
- default: return;
356
- }
357
- touch(it, rec);
358
- }
359
-
360
- const byTime = (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);
361
-
362
- /**
363
- * events → current items. Pure and deterministic: the same lines in any order give the same result, which
364
- * is what lets a union merge interleave two sides' lines safely.
365
- *
366
- * @param {Array<{n:number, raw:string, rec:object}>} lines readLines().lines (or anything of that shape)
367
- * @param {{now?: number, flags?: Array}} [opts] `flags` carries readLines' parse flags through
368
- */
369
- function fold(lines, { now = Date.now(), flags = [] } = {}) {
370
- const S = createState();
371
- S.flags.push(...flags);
372
- // 1 · schema
373
- const valid = [];
374
- for (const l of lines || []) {
375
- const err = lineErr(l.rec);
376
- if (err) { S.flags.push({ kind: 'invalid', line: l.n == null ? null : l.n, eid: (l.rec && isStr(l.rec.eid)) ? l.rec.eid : null, item: (l.rec && isStr(l.rec.item)) ? l.rec.item : null, initiative: (l.rec && isStr(l.rec.initiative)) ? l.rec.initiative : null, message: `line ${l.n} does not match the plan-items schema: ${err} — it has no effect` }); continue; }
377
- valid.push(l);
378
- }
379
- // 2 · byte-identical duplicates (a union-merge artefact) are dropped and counted; one eid with two contents is a flag
380
- let dupes = 0;
381
- const seenRaw = new Set();
382
- const unique = [];
383
- for (const l of valid) {
384
- if (seenRaw.has(l.raw)) { dupes++; continue; }
385
- seenRaw.add(l.raw);
386
- unique.push(l);
387
- }
388
- // 3 · sort by (at, eid) — and only then decide which of two same-eid lines wins, so the winner is order-free
389
- unique.sort((a, b) => byTime(a, b) || (a.raw < b.raw ? -1 : a.raw > b.raw ? 1 : 0));
390
- const seenEid = new Set();
391
- const ordered = [];
392
- for (const l of unique) {
393
- if (seenEid.has(l.rec.eid)) { flag(S, 'duplicate-event', l.rec, l.n, `event ${l.rec.eid} appears twice with different content — the later one has no effect`); continue; }
394
- seenEid.add(l.rec.eid);
395
- ordered.push(l);
396
- }
397
- // 4 · apply
398
- for (const l of ordered) applyLine(S, l.rec, l.n);
399
- return finish(S, { now, dupes });
400
- }
401
-
402
- /**
403
- * Every live item that comes after itself through its predecessors. The writer refuses a loop, but two writers can
404
- * race between their read and their append, and a union merge can join two branches' After lists, so the fold
405
- * checks too. Withdrawn ids are not in S.items and end a walk.
406
- */
407
- function loopItems(S) {
408
- const on = [];
409
- for (const [start, item] of S.items) {
410
- const seen = new Set();
411
- const stack = item.after.slice();
412
- while (stack.length) {
413
- const cur = stack.pop();
414
- if (cur === start) { on.push(start); break; }
415
- if (seen.has(cur)) continue;
416
- seen.add(cur);
417
- const it = S.items.get(cur);
418
- if (it) stack.push(...it.after);
419
- }
420
- }
421
- return on.sort();
422
- }
423
-
424
- /** Derived fields, batches and the result shape. Called once per fold. */
425
- function finish(S, { now = Date.now(), dupes = 0 } = {}) {
426
- for (const id of loopItems(S)) {
427
- const it = S.items.get(id);
428
- S.flags.push({ kind: 'after-loop', line: null, eid: null, item: id, initiative: it.initiative,
429
- message: `${id} comes after itself through its After list (two writes crossed, or a merge joined them) — change its After list` });
430
- }
431
- const todayUTC = new Date(now).toISOString().slice(0, 10);
432
- const items = {};
433
- const list = [...S.items.values()];
434
- list.sort((a, b) => (Date.parse(a.createdAt) - Date.parse(b.createdAt)) || a.id.localeCompare(b.id));
435
- for (const it of list) {
436
- const out = { ...it, after: it.after.slice(), history: it.history.slice() };
437
- out.late = !!it.due && it.due < todayUTC && it.state !== 'done';
438
- out.byLabel = label(it.updatedBy);
439
- items[it.id] = out;
440
- }
441
- const groups = new Map();
442
- for (const it of list) {
443
- if (!it.proposed || !it.createdBy.startsWith('agent:')) continue;
444
- const key = `${it.initiative}\u0000${it.createdBy}`;
445
- if (!groups.has(key)) groups.set(key, []);
446
- groups.get(key).push(it);
447
- }
448
- const batches = [...groups.values()].map((g) => ({
449
- initiative: g[0].initiative, by: g[0].createdBy, byLabel: label(g[0].createdBy), count: g.length,
450
- items: g.map((x) => x.id), firstAt: g[0].createdAt, lastAt: g[g.length - 1].createdAt,
451
- })).sort((a, b) => (Date.parse(a.firstAt) - Date.parse(b.firstAt)) || a.by.localeCompare(b.by));
452
- return {
453
- items, order: list.map((x) => x.id), batches, flags: S.flags.slice(), dupes,
454
- withdrawnCount: S.withdrawn.size, withdrawnIds: [...S.withdrawn].sort(),
455
- };
456
- }
457
-
458
- function readFold(root, { now } = {}) {
459
- const { lines, flags } = readLines(root);
460
- return fold(lines, { now, flags });
461
- }
462
-
463
- const progressLabel = planTree.progressLabel;
464
- /** Ledger-only progress for one initiative (R6): proposed and withdrawn items never count. */
465
- function progressOf(result, initiative) {
466
- const mine = Object.values(result.items).filter((it) => it.initiative === initiative);
467
- const its = mine.filter((it) => !it.proposed);
468
- const done = its.filter((it) => it.state === 'done').length;
469
- const proposed = mine.length - its.length;
470
- return { done, total: its.length, proposed, label: progressLabel(done, its.length, proposed) };
471
- }
472
-
473
- // ── attribution ───────────────────────────────────────────────────────────────────────────────────
474
- /**
475
- * Who a `pmos item` call is. A bound agent session FIRST, by its token: an agent that runs its commands in a PTY (or
476
- * wraps them with `script`) still has a terminal, and a terminal must not turn its writes into the PM's. Only agent
477
- * sessions get PMOS_SESSION (server.js), so the PM's own shell is still a terminal with no token: the PM. Else unknown.
478
- */
479
- function actorFromCli(opts = {}) {
480
- return actorLib.fromCli(opts); // one rule for every ledger (lib/actor.js); the defaults are actor.js's
481
- }
482
-
483
- // ── the writer ────────────────────────────────────────────────────────────────────────────────────
484
- const refuse = (status, error, index) => ({ ok: false, status, error, index: index == null ? null : index });
485
- const PM_ITEM_HINT = 'this item is the PM\'s (owner you) — an agent does not change it; ask the PM';
486
- const PM_MOVE_HINT = 'this item is the PM\'s (owner you) and the PM did not hand it to this session — ask the PM to hand it to you';
487
-
488
- /** Does adding `from → each of after` make a loop in the predecessor graph of `items`? */
489
- function makesLoop(items, from, after) {
490
- const seen = new Set();
491
- const stack = after.slice();
492
- while (stack.length) {
493
- const cur = stack.pop();
494
- if (cur === from) return true;
495
- if (seen.has(cur)) continue;
496
- seen.add(cur);
497
- const it = items.get(cur);
498
- if (it) stack.push(...it.after);
499
- }
500
- return false;
501
- }
502
-
503
- /**
504
- * Check one caller event against the working copy and build the record to write. Returns { rec } or a refusal.
505
- * The record's keys are emitted common-first, then the payload, in the order of the plan's §1.2.
506
- */
507
- function buildRecord(ctx, ev, index) {
508
- const { S, actor, allowKept, root, stagesCache, nextAt, handedTo } = ctx;
509
- if (!ev || typeof ev !== 'object' || Array.isArray(ev)) return refuse(400, 'each line must be an object', index);
510
- for (const k of WRITER_FIELDS) if (k in ev) return refuse(400, `field ${k} is set by the writer`, index);
511
- if (!EVENTS.includes(ev.ev)) return refuse(400, `unknown event ${JSON.stringify(ev.ev)} — one of ${EVENTS.join(', ')}`, index);
512
- if (ev.ev === 'kept' && !allowKept) return refuse(400, 'Keep is written only from the workspace page (Breakdown or Today).', index);
513
- if (ev.ev === 'planned' && 'item' in ev) return refuse(400, 'field item is set by the writer', index);
514
- const allowed = {
515
- planned: ['ev', 'initiative', 'title', 'stage', 'order', 'owner', 'after', 'start', 'due', 'milestone', 'note'],
516
- set: ['ev', 'item', 'initiative', 'fields'],
517
- moved: ['ev', 'item', 'initiative', 'to', 'evidence'],
518
- flagged: ['ev', 'item', 'initiative', 'blocked', 'reason'],
519
- withdrawn: ['ev', 'item', 'initiative'],
520
- kept: ['ev', 'initiative', 'batch', 'items'],
521
- }[ev.ev];
522
- for (const k of Object.keys(ev)) if (!allowed.includes(k)) return refuse(400, `field ${k} is not part of a ${ev.ev} line`, index);
523
-
524
- /* `floor` is the newest `at` of the item(s) this line changes: the fold sorts by `at`, so the line must sort after
525
- * them even when one of them is dated ahead of this clock (a union merge from a machine whose clock runs ahead). */
526
- const common = (initiative, item, floor) => {
527
- const rec = { v: 1, eid: newEventId(), ev: ev.ev };
528
- if (item) rec.item = item;
529
- Object.assign(rec, { initiative, by: actor.by, via: actor.via, at: nextAt(floor) });
530
- return rec;
531
- };
532
- // R23 (X35): the PM's items (owner `you`) are the PM's to change. An agent asks; it does not set or flag them.
533
- const agentOnPmItem = (it) => actor.by.startsWith('agent:') && it.owner === 'you';
534
- const stagesOf = (initiative) => {
535
- if (!stagesCache.has(initiative)) stagesCache.set(initiative, planTree.stagesFor(root, initiative));
536
- return stagesCache.get(initiative);
537
- };
538
- const resolveStage = (initiative, v, index2) => {
539
- const stages = stagesOf(initiative);
540
- const want = String(v == null ? '' : v).toLowerCase().replace(/\s+/g, ' ').trim();
541
- const hit = stages.find((s) => s.id === want || String(s.label).toLowerCase() === want);
542
- if (!hit) return refuse(400, `unknown stage ${JSON.stringify(v)} — the stages of ${initiative} are ${stages.map((s) => s.id).join(', ')}`, index2);
543
- return { id: hit.id };
544
- };
545
- /* Only the predecessors a call ADDS are checked: an item whose predecessor was withdrawn later must still be able to
546
- * drop that one, or another, from its list. `had` is the item's current list. */
547
- const checkAfter = (initiative, list, self, had = []) => {
548
- for (const id of list) {
549
- if (had.includes(id)) continue;
550
- if (id === self) return refuse(409, 'an item cannot come after itself', index);
551
- if (S.withdrawn.has(id)) return refuse(409, `${id} is withdrawn and cannot be a predecessor`, index);
552
- if (!S.items.has(id)) return refuse(404, `no item ${id} in this repository`, index);
553
- }
554
- if (self && makesLoop(S.items, self, list)) return refuse(409, 'this would make a loop', index);
555
- return null;
556
- };
557
- // An existing item named by a non-planned line, with its initiative checked against the id.
558
- const existing = () => {
559
- if (!isStr(ev.item) || !ID_RE.test(ev.item)) return refuse(400, 'item must be an item id, <initiative>.<10 characters>', index);
560
- const init = initiativeOf(ev.item);
561
- if (ev.initiative !== undefined && ev.initiative !== init) return refuse(400, `item ${ev.item} does not belong to initiative ${ev.initiative}`, index);
562
- if (S.withdrawn.has(ev.item)) return refuse(409, `${ev.item} is withdrawn`, index);
563
- const it = S.items.get(ev.item);
564
- if (!it) return refuse(404, `no item ${ev.item} in this repository`, index);
565
- return { it, init };
566
- };
567
-
568
- switch (ev.ev) {
569
- case 'planned': {
570
- const init = ev.initiative;
571
- if (!isStr(init) || !INIT_RE.test(init)) return refuse(400, 'initiative must be a record name', index);
572
- if (!planTree.recordPath(root, init)) return refuse(404, `no initiative ${init} in this repository`, index);
573
- const tErr = fieldErr('title', ev.title);
574
- if (tErr) return refuse(400, tErr, index);
575
- const st = resolveStage(init, ev.stage, index);
576
- if (st.ok === false) return st;
577
- let order = ev.order;
578
- if (order === undefined || order === null) {
579
- let max = 0;
580
- for (const it of S.items.values()) if (it.initiative === init && it.stage === st.id && it.order > max) max = it.order;
581
- order = max + 1024;
582
- } else if (fieldErr('order', order)) return refuse(400, fieldErr('order', order), index);
583
- const owner = ev.owner === undefined || ev.owner === null ? (actor.by.startsWith('pm:') ? 'you' : 'agent') : ev.owner;
584
- if (fieldErr('owner', owner)) return refuse(400, fieldErr('owner', owner), index);
585
- const after = ev.after === undefined || ev.after === null ? [] : ev.after;
586
- if (fieldErr('after', after)) return refuse(400, fieldErr('after', after), index);
587
- const start = ev.start === undefined ? null : ev.start;
588
- const due = ev.due === undefined ? null : ev.due;
589
- if (fieldErr('start', start)) return refuse(400, fieldErr('start', start), index);
590
- if (fieldErr('due', due)) return refuse(400, fieldErr('due', due), index);
591
- if (start && due && start > due) return refuse(400, 'start must be on or before due', index);
592
- const milestone = ev.milestone === undefined || ev.milestone === null ? false : ev.milestone;
593
- if (fieldErr('milestone', milestone)) return refuse(400, fieldErr('milestone', milestone), index);
594
- const note = ev.note === undefined || ev.note === null ? '' : ev.note;
595
- if (fieldErr('note', note)) return refuse(400, fieldErr('note', note), index);
596
- const a = checkAfter(init, after, null);
597
- if (a) return a;
598
- const id = newItemId(init, (x) => S.items.has(x) || S.withdrawn.has(x));
599
- const rec = common(init, id);
600
- Object.assign(rec, { title: ev.title.trim(), stage: st.id, order, owner, after: after.slice(), start, due, milestone, note });
601
- return { rec };
602
- }
603
- case 'set': {
604
- const ex = existing(); if (ex.ok === false) return ex;
605
- const { it, init } = ex;
606
- if (agentOnPmItem(it)) return refuse(403, PM_ITEM_HINT, index);
607
- const f = ev.fields;
608
- if (!f || typeof f !== 'object' || Array.isArray(f) || !Object.keys(f).length) return refuse(400, 'fields must name at least one of ' + SET_FIELDS.join(', '), index);
609
- const out = {};
610
- for (const k of Object.keys(f)) {
611
- if (!SET_FIELDS.includes(k)) return refuse(400, `field ${k} cannot be set — one of ${SET_FIELDS.join(', ')}`, index);
612
- let v = f[k];
613
- if ((k === 'start' || k === 'due' || k === 'note') && v === null) { out[k] = null; continue; }
614
- if (k === 'stage') { const st = resolveStage(init, v, index); if (st.ok === false) return st; out[k] = st.id; continue; }
615
- if (k === 'title' && isStr(v)) v = v.trim();
616
- const e = fieldErr(k, v);
617
- if (e) return refuse(400, e, index);
618
- out[k] = Array.isArray(v) ? v.slice() : v;
619
- }
620
- const start = 'start' in out ? out.start : it.start;
621
- const due = 'due' in out ? out.due : it.due;
622
- if (start && due && start > due) return refuse(400, 'start must be on or before due', index);
623
- if ('after' in out) { const a = checkAfter(init, out.after, it.id, it.after); if (a) return a; }
624
- const rec = common(init, it.id, it.updatedAt);
625
- rec.fields = out;
626
- return { rec };
627
- }
628
- case 'moved': {
629
- const ex = existing(); if (ex.ok === false) return ex;
630
- const { it, init } = ex;
631
- if (!STATES.includes(ev.to)) return refuse(400, `to must be one of ${STATES.join(', ')}`, index);
632
- if (agentOnPmItem(it) && !handedTo(it.id)) return refuse(403, PM_MOVE_HINT, index);
633
- /* done → done with evidence attaches it to an item that reads Done without evidence: the way to correct an agent
634
- * Done that forgot --evidence, from the CLI or the item sheet. Any other same-state move is refused. */
635
- const attach = ev.to === 'done' && it.state === 'done' && ev.evidence != null && (it.noEvidence || !it.evidence);
636
- if (ev.to === it.state && !attach) return refuse(409, `already ${STATE_LABEL[it.state]}`, index);
637
- let evidence;
638
- if (ev.evidence !== undefined && ev.evidence !== null) {
639
- evidence = isStr(ev.evidence) ? parseEvidence(ev.evidence) : ev.evidence;
640
- const e = evidence && !evidence.error ? evidenceErr(evidence) : (evidence ? evidence.error : 'evidence must be {kind, ref}');
641
- if (e) return refuse(400, e, index);
642
- evidence = { kind: evidence.kind, ref: evidence.ref };
643
- }
644
- const rec = common(init, it.id, it.updatedAt);
645
- rec.to = ev.to;
646
- if (evidence) rec.evidence = evidence;
647
- // override: the PM moved an item the agent owns. Computed here, never accepted from a caller. Attaching
648
- // evidence is not a move, so it is never an override.
649
- if (actor.by.startsWith('pm:') && it.owner === 'agent' && !attach) rec.override = true;
650
- return { rec };
651
- }
652
- case 'flagged': {
653
- const ex = existing(); if (ex.ok === false) return ex;
654
- const { it, init } = ex;
655
- if (agentOnPmItem(it)) return refuse(403, PM_ITEM_HINT, index);
656
- if (ev.blocked === true) {
657
- if (!oneLine(ev.reason, 200)) return refuse(400, 'a block reason is 1 to 200 characters on one line', index);
658
- const rec = common(init, it.id, it.updatedAt);
659
- Object.assign(rec, { blocked: true, reason: ev.reason.trim() });
660
- return { rec };
661
- }
662
- if (ev.blocked === false) {
663
- if (ev.reason !== undefined) return refuse(400, 'an unblock carries no reason', index);
664
- if (!it.blocked) return refuse(409, `${it.id} is not blocked`, index);
665
- const rec = common(init, it.id, it.updatedAt);
666
- rec.blocked = false;
667
- return { rec };
668
- }
669
- return refuse(400, 'blocked must be true or false', index);
670
- }
671
- case 'withdrawn': {
672
- const ex = existing(); if (ex.ok === false) return ex;
673
- /* Withdraw is terminal (the fold has no un-withdraw), so an agent withdraws only what it planned itself (R23).
674
- * The PM's items, and another session's, are the PM's to withdraw. */
675
- if (actor.by.startsWith('agent:') && ex.it.createdBy !== actor.by) {
676
- return refuse(403, 'an agent withdraws only the items it planned — the PM withdraws the rest', index);
677
- }
678
- return { rec: common(ex.init, ex.it.id, ex.it.updatedAt) };
679
- }
680
- case 'kept': {
681
- const init = ev.initiative;
682
- if (!isStr(init) || !INIT_RE.test(init)) return refuse(400, 'initiative must be a record name', index);
683
- if (!isStr(ev.batch) || !/^agent:/.test(ev.batch)) return refuse(400, 'batch must be the agent:<session> that planned the items', index);
684
- if (!Array.isArray(ev.items) || !ev.items.length || !ev.items.every((x) => isStr(x) && ID_RE.test(x))) return refuse(400, 'items must be a non-empty list of item ids', index);
685
- if (new Set(ev.items).size !== ev.items.length) return refuse(400, 'items names an item twice', index);
686
- /* The page names a batch as it was sent it (`agent:sess-<n>`, `#k` on a repeat), never with the token. Such a name
687
- * is resolved from the items: the batch is the actor that proposed the first of them, its page name must be the
688
- * one sent, and every item is checked against that actor below. The line records the actor in full. */
689
- let batch = ev.batch;
690
- if (!BY_RE.test(batch)) {
691
- const m = PAGE_BATCH_RE.exec(batch);
692
- const first = S.items.get(ev.items[0]);
693
- if (!m || !first || actorForPage(first.createdBy) !== m[1]) return refuse(409, `${ev.items[0]} is not a proposed item of this batch`, index);
694
- batch = first.createdBy;
695
- }
696
- let floor = null;
697
- for (const id of ev.items) {
698
- const it = S.items.get(id);
699
- if (!it || !it.proposed || it.createdBy !== batch || it.initiative !== init) return refuse(409, `${id} is not a proposed item of this batch`, index);
700
- if (!floor || Date.parse(it.updatedAt) > Date.parse(floor)) floor = it.updatedAt;
701
- }
702
- const rec = common(init, null, floor);
703
- Object.assign(rec, { batch, items: ev.items.slice() });
704
- return { rec };
705
- }
706
- default: return refuse(400, 'unknown event', index);
707
- }
708
- }
709
-
710
- /** `commit:1a2b3c4` → {kind, ref}. */
711
- function parseEvidence(s) {
712
- const m = /^([a-z]+):(.+)$/s.exec(String(s));
713
- if (!m) return { error: 'evidence is <kind>:<ref>, for example commit:1a2b3c4' };
714
- return { kind: m[1], ref: m[2] };
715
- }
716
-
717
- function append(root, events, actor, { now, allowKept, handedTo = () => false }) {
718
- if (!Array.isArray(events) || !events.length) return refuse(400, 'send at least one line', null);
719
- if (events.length > MAX_LINES) return refuse(413, `send at most ${MAX_LINES} lines at once`, null);
720
- let read;
721
- try { read = readLines(root); } catch (e) { return refuse(500, 'could not read the plan-items ledger — nothing was saved: ' + String((e && e.message) || e).slice(0, 160), null); }
722
- const base = fold(read.lines, { now: now == null ? Date.now() : now, flags: read.flags });
723
- // The working copy: the folded ledger, with each checked event of this call applied before the next is checked.
724
- const S = createState();
725
- for (const id of base.order) {
726
- const it = base.items[id];
727
- S.items.set(id, { ...it, after: it.after.slice(), history: it.history.slice() });
728
- }
729
- for (const id of base.withdrawnIds) S.withdrawn.add(id);
730
- /* Strictly increasing `at`. Lines written by one call (a planned then a set in the same millisecond) must
731
- * sort in the order they were written, and the fold sorts by (at, eid). A line dated more than a second
732
- * ahead of this clock is ignored for the GLOBAL floor, so one forged future date cannot drag every later write
733
- * with it; each line also gets a floor from its own item (`common(…, floor)`), so a write to an item whose newest
734
- * line is dated ahead still sorts after that line and is not silently lost. */
735
- const clock = now == null ? Date.now() : now;
736
- let last = 0;
737
- for (const l of read.lines) {
738
- const t = l.rec && isStr(l.rec.at) ? Date.parse(l.rec.at) : NaN;
739
- if (Number.isFinite(t) && t <= clock + 1000 && t > last) last = t;
740
- }
741
- const nextAt = (floorIso) => {
742
- const f = floorIso ? Date.parse(floorIso) + 1 : 0;
743
- const t = Math.max(now == null ? Date.now() : now, last + 1, Number.isFinite(f) ? f : 0);
744
- last = t;
745
- return new Date(t).toISOString();
746
- };
747
- const ctx = { S, actor, allowKept, root, stagesCache: new Map(), nextAt, handedTo };
748
- const recs = [];
749
- for (let i = 0; i < events.length; i++) {
750
- let r;
751
- try { r = buildRecord(ctx, events[i], i); }
752
- catch (e) { return refuse(500, 'could not check the line — nothing was saved: ' + String((e && e.message) || e).slice(0, 160), i); }
753
- if (r.ok === false) return r;
754
- applyLine(S, r.rec, null);
755
- recs.push(r.rec);
756
- }
757
- let file;
758
- try { file = ledgerPath(root); fs.mkdirSync(path.dirname(file), { recursive: true }); }
759
- catch { return refuse(500, 'could not record — nothing was saved', null); }
760
- const guard = createSinkGuard({ sink: file, what: 'plan items' });
761
- let wrote = 0;
762
- for (const rec of recs) {
763
- try { guard.appendLine(file, rec); wrote++; }
764
- catch {
765
- 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);
766
- }
767
- }
768
- const touched = [];
769
- for (const r of recs) for (const id of (r.ev === 'kept' ? r.items : [r.item])) if (!touched.includes(id)) touched.push(id);
770
- return { ok: true, written: recs.map((r) => ({ eid: r.eid, ev: r.ev, item: r.item || null })), items: touched };
771
- }
772
-
773
- /**
774
- * The workspace page's route. The only path that accepts `kept`. The route passes the actor lib/pm-guard.js answered;
775
- * with none, actor.fromUi(). Either way it must be the PM via `ui`: anything else is refused, not rewritten.
776
- */
777
- function appendFromUi(root, events, { now, actor } = {}) {
778
- const who = actor === undefined ? actorLib.fromUi() : actor;
779
- if (!actorLib.isPm(who) || who.via !== 'ui') return refuse(403, 'the page writes only as the PM, after the guard', null);
780
- return append(root, events, { by: who.by, via: 'ui' }, { now, allowKept: true });
781
- }
782
- /** `pmos item`. The actor comes from the terminal and PMOS_SESSION, never from an argument. Refuses `kept`. */
783
- function appendFromCli(root, events, { now, isTTY, env, sessDir } = {}) {
784
- const opts = {};
785
- if (isTTY !== undefined) opts.isTTY = isTTY;
786
- if (env !== undefined) opts.env = env;
787
- const who = actorFromCli(opts);
788
- return append(root, events, who, { now, allowKept: false, handedTo: handedToSession(who, env, sessDir) });
789
- }
790
- /**
791
- * itemId → true when the PM handed that item to the actor's session (R23). The session meta lives in the workspace's
792
- * runtime state (`<state root>/sessions`), resolved from the environment the way the server resolves it; a test passes
793
- * `sessDir`. The meta is read at most once per call.
794
- */
795
- function handedToSession(who, env, sessDir) {
796
- const token = actorLib.sessionOf(who);
797
- if (!token) return () => false;
798
- let s;
799
- return (itemId) => {
800
- if (s === undefined) {
801
- const dir = sessDir || path.join(stateRoot.defaultRoot({ env: env || process.env }), 'sessions');
802
- s = actorLib.sessionForToken(token, { sessDir: dir });
803
- }
804
- return !!s && s.plan_item === itemId;
805
- };
806
- }
807
-
808
- // ── needs-you rows (Keep cards, agent Done with no evidence, forged Keep lines) ─────────────────────
809
- function needsFor(root, { now } = {}) {
810
- const r = readFold(root, { now });
811
- const rows = [];
812
- // The rows go to the page (/api/needs), so each batch is named as the Breakdown names it, without the token.
813
- const names = batchNames(r.batches, (b) => b.initiative);
814
- r.batches.forEach((b, i) => {
815
- rows.push({ run_id: `keep:${b.initiative}:${names[i]}`, initiative_id: b.initiative, kind: 'keep', derived: true,
816
- question: b.count === 1 ? '1 item' : `${b.count} items`, doc: null,
817
- batch: { by: names[i], byLabel: b.byLabel, count: b.count, items: b.items.slice() } });
818
- });
819
- for (const id of r.order) {
820
- const it = r.items[id];
821
- if (!it.noEvidence || it.state !== 'done') continue;
822
- rows.push({ run_id: `evidence:${id}`, initiative_id: it.initiative, kind: 'evidence', derived: true,
823
- question: `"${it.title}" reads Done with no evidence`, doc: null, item: id });
824
- }
825
- for (const f of r.flags) {
826
- if (f.kind !== 'kept-not-ui' && f.kind !== 'kept-not-pm') continue;
827
- rows.push({ run_id: `plan-flag:${f.eid}`, initiative_id: f.initiative, kind: 'plan-flag', derived: true,
828
- question: f.kind === 'kept-not-ui'
829
- ? 'A Keep line in plan-items.jsonl was not written from the workspace page. It has no effect.'
830
- : 'A Keep line in plan-items.jsonl was not written by the PM. It has no effect.',
831
- doc: null, flag: { kind: f.kind, eid: f.eid, line: f.line } });
832
- }
833
- return rows;
834
- }
835
-
836
- // ── the poll signature (§2.5) ───────────────────────────────────────────────────────────────────
837
- const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
838
- function itemsSig(root) {
839
- let st;
840
- try { st = fs.statSync(ledgerPath(root)); } catch { return 'none'; }
841
- return sha1(`${st.size}:${st.mtimeMs}:${st.ino}`);
842
- }
843
- /** What the ladder reads besides the records: git HEAD and the records (board-cache), and the other ledgers. */
844
- function evidenceSig(root) {
845
- const boardCache = require('./board-cache');
846
- let runsDir = null;
847
- try { runsDir = engine().layout(root).runsDir; } catch { runsDir = null; }
848
- const parts = [];
849
- if (runsDir) {
850
- let names = [];
851
- try { names = fs.readdirSync(runsDir).filter((n) => n.endsWith('.jsonl') && n !== LEDGER).sort(); } catch { names = []; }
852
- for (const n of names) {
853
- try { const st = fs.statSync(path.join(runsDir, n)); parts.push(`${n}:${st.size}:${st.mtimeMs}`); } catch { /* vanished mid-scan */ }
854
- }
855
- }
856
- return sha1(boardCache.signature(root) + '|' + parts.join(','));
857
- }
858
- /**
859
- * One signature per registered workspace, computed per poll and never stored (D33). `items` is one stat;
860
- * `evidence` is computed only for the watched workspaces (at most 4), because it runs one `git rev-parse`.
861
- */
862
- function ledgerSigs(workspaces, watched) {
863
- const want = new Set(String(watched || '').split(',').map((s) => s.trim()).filter(Boolean).slice(0, 4));
864
- const out = {};
865
- for (const w of workspaces || []) {
866
- if (!w || !w.id || !w.root) continue;
867
- let items = 'none';
868
- try { items = itemsSig(w.root); } catch { items = 'unreadable'; }
869
- let evidence = null;
870
- if (want.has(w.id)) { try { evidence = evidenceSig(w.root); } catch { evidence = 'unreadable'; } }
871
- out[w.id] = { items, evidence };
872
- }
873
- return out;
874
- }
875
-
876
- /**
877
- * The Breakdown layout of one initiative and the signature it answers to (GET /api/plan-items). `w` is {id, root};
878
- * `ladder: false` is the fast read with no owed rows.
879
- *
880
- * The signature is taken FIRST, before the ledger and the ladder are read. The page keeps it and reads the layout again
881
- * only when a poll returns a different one (panel-breakdown.js, "When it reads again"). Taken after the reads, a line
882
- * appended while they ran (the ladder alone is about 800 ms on a real repository) gave the page the signature WITH the
883
- * line and the items WITHOUT it: every later poll matched, and the line stayed off the screen until some other write.
884
- * Taken first, a write during the read leaves a signature older than the data at worst, which costs one more read.
885
- * The layout is the page's: no session token in it (layoutForPage).
886
- */
887
- function readLayout(w, initiative, { ladder = true, now = Date.now() } = {}) {
888
- const sig = ledgerSigs([w], w.id)[w.id];
889
- const fold = readFold(w.root, { now });
890
- let lad = null;
891
- if (ladder) {
892
- try { lad = consoleState.ladder({ root: w.root, id: initiative }); }
893
- catch (e) { lad = { ok: false, error: 'the ladder could not be derived: ' + String((e && e.message) || 'error').slice(0, 140) }; }
894
- }
895
- return { sig, layout: layoutForPage(planTree.layout({ root: w.root, initiative, fold, ladder: lad, now })) };
896
- }
897
-
898
- // ── Hand to agent: the prompt a bound session starts with (§2.4) ───────────────────────────────────
899
- /* PMOS_BIN (server.js) is this workspace's own bin/pmos.js, so the agent never runs an older global `pmos` that has
900
- * no `item` verb. `after` names only live predecessors: the caller drops withdrawn ids. */
901
- function handoffPrompt(item, initiativeId, stageLabel) {
902
- const first = `Finish the work item "${item.title}" (${item.id}) for initiative ${initiativeId}.`;
903
- const prompt = [
904
- first,
905
- `Stage: ${stageLabel}. Due: ${item.due || 'none'}. After: ${item.after && item.after.length ? item.after.join(', ') : 'none'}.`,
906
- `Note: ${item.note ? String(item.note).replace(/\s*\n\s*/g, ' ') : 'none'}.`,
907
- 'Work on the branch this session started on. Do not write planning/work-items.jsonl.',
908
- 'Report progress with this workspace\'s pmos item command, for example:',
909
- ` node "$PMOS_BIN" item move ${item.id} doing`,
910
- ` node "$PMOS_BIN" item move ${item.id} done --evidence commit:<sha>`,
911
- 'An agent Done without --evidence reads "no evidence" and goes to the PM.',
912
- 'If you forgot --evidence, run the same move again with it.',
913
- ].join('\n');
914
- return { first, prompt };
915
- }
916
-
917
- module.exports = {
918
- LEDGER, EVENTS, STATES, PM_SLUG, ID_RE, INIT_RE, SESSION_RE,
919
- ledgerRoot, ledgerPath, newItemId, newEventId, initiativeOf,
920
- readLines, parseText, fold, readFold, progressOf, progressLabel, lineErr,
921
- actorFromCli, appendFromUi, appendFromCli, needsFor, ledgerSigs, readLayout, handoffPrompt, parseEvidence,
922
- };