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,85 @@
1
+ ---
2
+ type: Guide
3
+ title: "Today: what needs you"
4
+ description: "The eight kinds of card on Today, the one button on each card, the count, the empty state, and the pmos today command."
5
+ timestamp: 2026-10-05
6
+ ---
7
+
8
+ # Today: what needs you
9
+
10
+ Today lists only what waits for you. Each card is one thing that only you can close. When nothing waits, Today says so and names the next scheduled draft, for example "Nothing needs you. Next: biweekly draft Fri 16 Oct."
11
+
12
+ Press **Today** in the top bar, or press `g` and then `t`, to open it. The number on the Today button is the number of cards in the scope you chose: the repository, or All repositories. It is the same number that Today shows at the top of the list.
13
+
14
+ ## A card
15
+
16
+ Every card has four parts.
17
+
18
+ - **Cause.** One sentence: why the card is here.
19
+ - **Evidence.** The record that shows it, for example a run id, a file or a date.
20
+ - **One button.** The act that closes the card. The verb comes first, for example **Review**, **Keep** or **Open**.
21
+ - **The three dots.** A menu with **Open the initiative**, **Copy the command** and **Why is this here?**. The menu has the same items on every card. A touch screen and a keyboard reach them as a mouse does.
22
+
23
+ When you finish the act, the card leaves and the count goes down by one. You do not dismiss a card. A card leaves only when its cause is gone.
24
+
25
+ ## The eight kinds
26
+
27
+ Today puts the cards in this order. Within a kind, the cards keep a fixed order, so a card does not move while you read.
28
+
29
+ | Kind | What it is | The button |
30
+ |---|---|---|
31
+ | 1. A gate is owed | A run waits for your acceptance, an outcome check is due, or a report is ready to issue. | Review, Check or Issue |
32
+ | 2. A session is waiting | A session in a terminal asked you a question and waits for the answer. | Open |
33
+ | 3. A step you returned | You returned an acceptance and the initiative has not been accepted since. | Open |
34
+ | 4. Work to check | An item is in Review, or an item is Done with no evidence. | Open |
35
+ | 5. Keep an agent breakdown | An agent proposed items. They count in no progress until you keep them. | Keep |
36
+ | 6. A triaged capture | A capture was triaged and waits for you to add it or dismiss it. | Open |
37
+ | 7. Cycle review | The cycle review is due. You confirm what is in Now, or move it. | Open |
38
+ | 8. Needs repair | A schedule left no output, a line from the PM has no attestation, or a record has no anchor. Today shows the card until it is fixed. | Open |
39
+
40
+ A stalled initiative is not a card. It shows as a health reason on the initiative, in the list and in its detail.
41
+
42
+ A source that cannot be read is not a card either. It shows in the notice at the top of Today, so a source that fails never looks like an empty list.
43
+
44
+ ## Close a gate on a run
45
+
46
+ A card of kind 1 for a run has the button **Review**. The steps are:
47
+
48
+ 1. Press **Review** on the card. The card opens in place and shows what the evaluator said.
49
+ 2. Press **Right** or **Wrong** to say whether the evaluator was right.
50
+ 3. Optional: press **Add a note** and write one line on why.
51
+ 4. Press **Accept** to accept the run, or **Return** to send it back.
52
+
53
+ That is four taps, or three taps without the note. The workspace records your act in the acceptance ledger. The note and your label are part of that record. If the workspace cannot record the act, the card stays and a message says that nothing was recorded.
54
+
55
+ ## The scope
56
+
57
+ The switch at the top of Today has two choices.
58
+
59
+ - **Repository.** The cards of the repository you chose. The number on the Today button counts these cards.
60
+ - **All repositories.** The cards of every repository. The count at the top says how many repositories it counts across.
61
+
62
+ The number on the Today button and the number at the top of the list are the same in both choices.
63
+
64
+ ## Short lists
65
+
66
+ Today shows the first three cards. Press **Show more** to see the rest, and **Show fewer** to fold them again. The count always includes every card.
67
+
68
+ ## A source that cannot be read
69
+
70
+ When Today cannot read a source, for example a broken needs file, it says so in a notice at the top of the list. It does not show an empty list and it does not say that nothing needs you. The notice names the source and the reason. Today still shows the cards of the sources it could read.
71
+
72
+ ## From a terminal
73
+
74
+ `pmos today` prints the same list as Today, in the same order, with the kind first.
75
+
76
+ ```
77
+ pmos today # the repository you are in
78
+ pmos today --ws <id> # one repository by its id
79
+ pmos today --json # the list as JSON
80
+ pmos today --root <dir> # read a repository folder without a server
81
+ ```
82
+
83
+ With the workspace server running, the command asks the server, so it also lists the sessions that wait for you. Without the server it reads the repository files and prints a line that says it did not read the sessions. The command prints "Nothing needs you" and the next draft when the list is empty. It exits with 0 when it read the list, 1 when it could not read it, and 2 when you gave it an option it does not have.
84
+
85
+ The page and the command read the route `GET /api/today?ws=<id>`. The route changes nothing.
@@ -102,7 +102,7 @@ The workspace reads the TypeSafe key only on this machine. After you save the ke
102
102
 
103
103
  ## Set it up
104
104
 
105
- 1. Open **validate** in the settings and tools drawer.
105
+ 1. Open Settings. Under Validate consent, press **Open Validate**. Validate opens as a tab.
106
106
  2. Under **TypeSafe key**, paste the key and press **save**. Press **remove** to delete a saved key.
107
107
  3. Under **this repository**, press **turn on**. This choice applies to the selected repository only.
108
108
  4. Enter the paths that the workspace must never send, one for each line. A path ends with `/` for a folder. A `*` matches any characters. Press **save excluded paths**.
@@ -0,0 +1,91 @@
1
+ 'use strict';
2
+ /* actor.js — who a write is, read from the CALL PATH, never from a field (pm-os-absorb, PRD R23 and R25).
3
+ *
4
+ * fromUi() → { by: 'pm:wawan', via: 'ui' } the page, after the route's own checks
5
+ * fromCli({isTTY, env}) → { by, via: 'cli' } a bound agent session, else a terminal (the PM), else unknown
6
+ * isPm(actor) · isAgent(actor) · sessionOf(actor)
7
+ * pageName(by) → 'agent:sess-<n>' for 'agent:sess-<n>.<8hex>', else `by` unchanged
8
+ *
9
+ * F5: a ledger line's `by` carries the session token; the page must never see it. Every GET route and every needs
10
+ * provider maps each `by` it answers through pageName (proposals, moves, item owners, report attributions).
11
+ *
12
+ * The placements, measurements and issues writers take their actor from here, so the four ledgers attribute one way.
13
+ * A caller never passes `by` or `via`: a writer that accepted them would make attribution something a caller chooses.
14
+ *
15
+ * THE SESSION-TOKEN MAP (Run 2, slice 2A). server.js gives each agent session a token, `sess-<n>.<8 hex>`, in its PTY
16
+ * environment as PMOS_SESSION. The token itself is never written to disk or sent to the page (D08, F5). server.js
17
+ * persists its SHA-256 (`tokenHash`) in the session's own meta file, so after a restart the map still answers which
18
+ * session a token belongs to and which initiative that session is bound to. The map is read from the files each time
19
+ * (no cache, D33), so a CLI with no server reads the same answer as the server.
20
+ *
21
+ * tokenHash(token) → 64 hex characters, or null for a value that is not a token
22
+ * sessionForToken(token, { sessDir }) → { session, initiative } | null
23
+ *
24
+ * HONEST LIMIT. A token is an attribution, not a credential: a process that unsets PMOS_SESSION is no longer an agent
25
+ * to fromCli. pm-guard.js requirePmCli asks such a caller to type the id of a PM-only act (X12); CI detection is the
26
+ * backstop for the rest.
27
+ */
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const crypto = require('crypto');
31
+
32
+ const PM_SLUG = 'wawan';
33
+ const SESSION_RE = /^sess-\d+\.[0-9a-f]{8}$/;
34
+ const BY_RE = /^(?:pm:[a-z0-9_-]{1,40}|agent:unknown|agent:sess-\d+\.[0-9a-f]{8})$/;
35
+ const VIA = ['ui', 'cli'];
36
+
37
+ const PM = Object.freeze({ by: `pm:${PM_SLUG}`, via: 'ui' });
38
+
39
+ /** The page. Call it only after the route has run its checks (pm-guard.js requirePmPress for a PM-only act). */
40
+ function fromUi() { return { by: PM.by, via: 'ui' }; }
41
+
42
+ /**
43
+ * A `pmos <verb>` call. A bound agent session FIRST, by its token: an agent that runs its commands in a PTY (or wraps
44
+ * them with `script`) still has a terminal, and a terminal must not turn its writes into the PM's. Only agent sessions
45
+ * get PMOS_SESSION (server.js spawnSession), so the PM's own shell is a terminal with no token: the PM. Else unknown.
46
+ */
47
+ function fromCli({ isTTY = !!(process.stdin.isTTY && process.stdout.isTTY), env = process.env } = {}) {
48
+ const tok = env && env.PMOS_SESSION;
49
+ if (typeof tok === 'string' && SESSION_RE.test(tok)) return { by: `agent:${tok}`, via: 'cli' };
50
+ if (isTTY) return { by: `pm:${PM_SLUG}`, via: 'cli' };
51
+ return { by: 'agent:unknown', via: 'cli' };
52
+ }
53
+
54
+ const valid = (a) => !!a && typeof a.by === 'string' && BY_RE.test(a.by) && VIA.includes(a.via);
55
+ const isPm = (a) => valid(a) && a.by.startsWith('pm:');
56
+ const isAgent = (a) => valid(a) && a.by.startsWith('agent:');
57
+ /** The session token of an agent actor, or null. */
58
+ const sessionOf = (a) => (isAgent(a) && a.by !== 'agent:unknown' ? a.by.slice('agent:'.length) : null);
59
+
60
+ /** The name the page may show. Strips the session token's secret suffix; anything else passes through. */
61
+ function pageName(by) {
62
+ if (typeof by !== 'string') return by;
63
+ const m = /^(agent:sess-\d+)\.[0-9a-f]{8}$/.exec(by);
64
+ return m ? m[1] : by;
65
+ }
66
+
67
+ /** The SHA-256 of a session token. The map stores this, never the token. */
68
+ function tokenHash(token) {
69
+ if (typeof token !== 'string' || !SESSION_RE.test(token)) return null;
70
+ return crypto.createHash('sha256').update(token).digest('hex');
71
+ }
72
+
73
+ /**
74
+ * The session a token belongs to, read from the session meta files server.js persists (`<sessDir>/<id>.json`). The id
75
+ * part of the token names the file, and the stored hash must match: a token with the right id and a wrong suffix is not
76
+ * that session. Null when there is no such session or no hash on record. `plan_item` is the item Hand to agent gave the
77
+ * session (null for any other session).
78
+ */
79
+ function sessionForToken(token, { sessDir } = {}) {
80
+ const h = tokenHash(token);
81
+ if (!h || typeof sessDir !== 'string' || !sessDir) return null;
82
+ const id = token.slice(0, token.indexOf('.'));
83
+ let meta;
84
+ try { meta = JSON.parse(fs.readFileSync(path.join(sessDir, id + '.json'), 'utf8')); } catch { return null; }
85
+ if (!meta || meta.id !== id || typeof meta.tokenHash !== 'string' || meta.tokenHash.length !== h.length) return null;
86
+ if (!crypto.timingSafeEqual(Buffer.from(meta.tokenHash), Buffer.from(h))) return null;
87
+ const str = (v) => (typeof v === 'string' && v ? v : null);
88
+ return { session: id, initiative: str(meta.initiative), plan_item: str(meta.plan_item) };
89
+ }
90
+
91
+ module.exports = { tokenHash, sessionForToken, PM_SLUG, SESSION_RE, BY_RE, VIA, fromUi, fromCli, valid, isPm, isAgent, sessionOf, pageName };
@@ -32,9 +32,38 @@ const REFRESH_TIMEOUT_MS = 120000; // a derivation that cannot finish in 2 min
32
32
  const cache = new Map(); // root -> { sig, at, value, error }
33
33
  const inflight = new Map(); // root -> true
34
34
 
35
+ // headSha runs on every signature, and the needs poll asks for a signature per registered tree several times per
36
+ // request, so a `git rev-parse` each time held the server for seconds with twenty trees registered. The answer is
37
+ // kept per root while the files a commit, checkout or reset touches (HEAD, its ref, packed-refs, the HEAD reflog)
38
+ // are unchanged; anything unreadable falls back to asking git.
39
+ const headMemo = new Map(); // root -> { key, sha }
40
+ function gitDirOf(root) {
41
+ const dotgit = path.join(root, '.git');
42
+ const st = fs.statSync(dotgit);
43
+ if (st.isDirectory()) return { dir: dotgit, common: dotgit };
44
+ const m = /^gitdir:\s*(.+)\s*$/m.exec(fs.readFileSync(dotgit, 'utf8'));
45
+ if (!m) throw new Error('no gitdir');
46
+ const dir = path.resolve(root, m[1].trim());
47
+ let common = dir;
48
+ try { common = path.resolve(dir, fs.readFileSync(path.join(dir, 'commondir'), 'utf8').trim()); } catch { /* not a linked worktree */ }
49
+ return { dir, common };
50
+ }
51
+ function headKey(root) {
52
+ const { dir, common } = gitDirOf(root);
53
+ const head = fs.readFileSync(path.join(dir, 'HEAD'), 'utf8').trim();
54
+ const stat = (f) => { try { const st = fs.statSync(f); return `${st.size}:${st.mtimeMs}:${st.ino}`; } catch { return '-'; } };
55
+ const ref = /^ref:\s*(.+)$/.exec(head);
56
+ return [head, ref ? stat(path.join(common, ref[1])) : '', stat(path.join(common, 'packed-refs')), stat(path.join(dir, 'logs', 'HEAD'))].join('|');
57
+ }
35
58
  function headSha(root) {
59
+ let key = null;
60
+ try { key = headKey(root); } catch { key = null; }
61
+ const hit = headMemo.get(root);
62
+ if (key && hit && hit.key === key) return hit.sha;
36
63
  const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', timeout: 5000 });
37
- return r.status === 0 && r.stdout ? r.stdout.trim() : 'no-head';
64
+ const sha = r.status === 0 && r.stdout ? r.stdout.trim() : 'no-head';
65
+ if (key) headMemo.set(root, { key, sha });
66
+ return sha;
38
67
  }
39
68
 
40
69
  // The records' own state, so an UNCOMMITTED edit still invalidates. Cheap: one readdir + one stat
@@ -42,8 +71,9 @@ function headSha(root) {
42
71
  function recordsSig(root) {
43
72
  // Both layouts (pmos-self `planning/`, a PM repository's `pmos/state/`), and the ledgers the
44
73
  // board's rows depend on: an uncommitted lifecycle or acceptance line must not wait out the TTL (D70).
74
+ // pm-os-absorb: plan-items.jsonl (Run 4's Build rule reads items) and issues.jsonl (Run 5's report lane reads issues).
45
75
  const dirs = [path.join(root, 'planning', 'initiatives'), path.join(root, 'pmos', 'state', 'initiatives')];
46
- const ledgers = ['lifecycle.jsonl', 'acceptances.jsonl'].flatMap((f) => [
76
+ const ledgers = ['lifecycle.jsonl', 'acceptances.jsonl', 'plan-items.jsonl', 'issues.jsonl'].flatMap((f) => [
47
77
  path.join(root, 'planning', 'runs', f), path.join(root, 'pmos', 'state', f)]);
48
78
  let total = 0; let newest = 0; let any = false;
49
79
  for (const dir of dirs) {
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+ /* board-cli.js — `pmos board`: the Board's items, by column, from a terminal (pm-os-absorb Run 4, slice 4D, PRD R24).
3
+ *
4
+ * pmos board [--mine | --init <initiative>] [--json] [--root <dir>]
5
+ *
6
+ * The SAME fold the Board page reads (lib/board-fold.js, the one behind GET /api/board), so a terminal line and a card
7
+ * cannot disagree about an item. No server is needed: the command reads the ledgers of the repository. With no flag it
8
+ * prints the Active filter (an item with an event in the last stall_days, an open item under an initiative in Now, and
9
+ * your own items). `--mine` prints your own items. `--init <initiative>` prints one initiative. `--json` prints the same
10
+ * answer as GET /api/board for that filter, without the workspace id. The command only reads: it writes no line.
11
+ *
12
+ * Exit codes: 0 done, 1 refused (an initiative that is not on the Board, an unreadable repository), 2 usage.
13
+ */
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { execFileSync } = require('child_process');
17
+ const consoleState = require('./console-state');
18
+ const boardFold = require('./board-fold.js');
19
+ const { parseArgs } = require('./schedule-cli');
20
+
21
+ const COMMANDS = { board: [] };
22
+ const USAGE = [
23
+ 'usage: pmos board [--mine | --init <initiative>] [--json] [--root <dir>] — the items of every initiative, by column',
24
+ ' pmos board the Active filter: recent events, open items under Now, and your own items',
25
+ ' pmos board --mine your own items',
26
+ ' pmos board --init <initiative> one initiative',
27
+ ' pmos board --json the answer of GET /api/board, for scripts',
28
+ 'Done shows the last 14 days. A work initiative only: a document initiative has no item on the Board.',
29
+ ];
30
+ const BOOL = new Set(['json', 'mine', 'help']);
31
+ const VALUE = new Set(['init', 'root']);
32
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
33
+ const COLUMNS = [['todo', 'Todo'], ['doing', 'Doing'], ['review', 'Review'], ['done', 'Done']];
34
+
35
+ function parse(argv) {
36
+ const rest = []; const bools = {};
37
+ for (const a of argv) {
38
+ if (a.startsWith('--') && BOOL.has(a.slice(2))) bools[a.slice(2)] = true;
39
+ else if (a === '-h') bools.help = true;
40
+ else rest.push(a);
41
+ }
42
+ const { pos, flags } = parseArgs(rest);
43
+ return { pos, flags: { ...flags, ...bools } };
44
+ }
45
+
46
+ function repoRoot(cwd) {
47
+ try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; }
48
+ catch { return cwd; }
49
+ }
50
+ const shortDate = (d) => { const t = new Date(d + 'T00:00:00Z'); return `${t.getUTCDate()} ${MONTHS[t.getUTCMonth()]}`; };
51
+ const ownerOf = (o) => (o === 'you' ? 'you' : o === 'agent' ? 'agent' : String(o).replace(/^[a-z]+:/, ''));
52
+
53
+ /** One card as a line: initiative · stage · "title" · owner, then what a person must notice. */
54
+ function line(it, titleOf) {
55
+ const bits = [titleOf[it.initiative] || it.initiative, it.stageLabel, `"${it.title}"`, ownerOf(it.owner)];
56
+ if (it.due) bits.push(`due ${shortDate(it.due)}`);
57
+ if (it.blocked) bits.push(`blocked: ${it.blocked.reason}`);
58
+ if (it.noEvidence) bits.push('no evidence');
59
+ if (it.late) bits.push('late');
60
+ if (it.overrideShown) bits.push('moved by you');
61
+ if (it.proposed) bits.push('proposed');
62
+ if (it.factory && it.factory.notMerged && it.factory.label) bits.push(it.factory.label);
63
+ return bits.join(' · ');
64
+ }
65
+
66
+ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd(), now } = {}) {
67
+ const args = argv[0] === 'board' ? argv.slice(1) : argv.slice();
68
+ const { pos, flags } = parse(args);
69
+ if (flags.help) { for (const l of USAGE) say(l); return 0; }
70
+ for (const k of Object.keys(flags)) {
71
+ if (!BOOL.has(k) && !VALUE.has(k)) { say(`unknown option --${k}`); return 2; }
72
+ if (VALUE.has(k) && flags[k] === '') { say(`--${k} needs a value`); return 2; }
73
+ }
74
+ if (pos.length) { say(`pmos board takes no argument (${pos[0]}). To show one initiative, type: pmos board --init <initiative>`); return 2; }
75
+ if (flags.mine && flags.init) { say('--mine and --init cannot be used together. Choose one filter.'); return 2; }
76
+ const json = !!flags.json;
77
+ const filter = flags.mine ? 'mine' : flags.init ? 'init:' + flags.init : 'active';
78
+ const fail = (error, code = 1) => { if (json) say(JSON.stringify({ ok: false, error })); else say(`✗ ${error}`); return code; };
79
+ const root = flags.root ? path.resolve(cwd, flags.root) : repoRoot(cwd);
80
+ // a root that is not a PMOS repository is refused: the fold would read it as a repository with no initiatives
81
+ try {
82
+ const loaded = consoleState.loadEngine();
83
+ if (!loaded.ok) return fail('the workflow engine is not installed: ' + loaded.error);
84
+ if (!fs.statSync(root).isDirectory() || !fs.statSync(loaded.engine.layout(root).initiatives).isDirectory()) throw new Error('no initiatives directory');
85
+ } catch (e) { return fail(`${root} is not a PMOS repository (no initiatives directory)`); }
86
+ let out;
87
+ try { out = boardFold.boardFor({ id: 'cli', root }, { filter, now }); }
88
+ catch (e) { return fail('the Board could not be read: ' + String((e && e.message) || e).slice(0, 200)); }
89
+ if (!out.ok) return fail(out.error, out.status === 400 ? 2 : 1);
90
+ if (json) { say(JSON.stringify(out)); return 0; }
91
+ const titleOf = Object.fromEntries(out.initiatives.map((i) => [i.id, i.title]));
92
+ const label = out.filter === 'active' ? 'Active' : out.filter === 'mine' ? 'Mine' : out.filter.slice(5);
93
+ const n = COLUMNS.reduce((s, [k]) => s + out.board[k].length, 0);
94
+ say(`Board · ${label} · ${n === 1 ? '1 item' : n + ' items'}`);
95
+ for (const [k, name] of COLUMNS) {
96
+ const ids = out.board[k];
97
+ say(`${name} · ${ids.length}${k === 'done' && out.board.doneOlder ? ` · ${out.board.doneOlder} older in the Tree` : ''}`);
98
+ for (const id of ids) say(' ' + line(out.items[id], titleOf));
99
+ }
100
+ const rel = out.releases || [];
101
+ if (rel.length) {
102
+ say(`Release · ${rel.length}`);
103
+ for (const r of rel) say(` ${r.initiativeTitle} · batch ${r.batch} · ${r.items.length === 1 ? '1 work item' : r.items.length + ' work items'} ready`);
104
+ }
105
+ return 0;
106
+ }
107
+
108
+ module.exports = { run, COMMANDS, USAGE };
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+ /* board-fold.js — the Board's ONE fold: every work item of every work-lane initiative, in its column (pm-os-absorb Run 4,
3
+ * slice 4D; PRD R9, R19, R12 kind 4; D86 points 1 and 4).
4
+ *
5
+ * boardFor(w, { filter, now }) → { ok: true, filter, now, sig, initiatives, items, board, counts }
6
+ * | { ok: false, status, error }
7
+ * parseFilter(text) → { kind: 'active' | 'mine' | 'init', init? } | null
8
+ * workInitiatives(root) → [{ id, title, lane, laneLabel }] the work lanes only (D86 point 4)
9
+ * factoryView(entry, item) → { item, state, pr, integrated, notMerged, label } | null
10
+ *
11
+ * THREE READERS, ONE ANSWER. GET /api/board (lib/routes-board.js), `pmos board` (lib/board-cli.js) and the Today rows of
12
+ * lib/work-check-needs.js all read through this file, so a card, a terminal line and a Today card cannot disagree about
13
+ * an item. The fold is lib/plan-items.js's: a card's column is its tree row's state, because both read the same fold and
14
+ * lib/plan-tree.js layout() builds each initiative's columns (R19, the same 14-day Done window).
15
+ *
16
+ * FILTERS (R9). `active` is the default: an item with an event in the last stall_days (planning/plan-rules.json), an open
17
+ * item under an initiative that is in Now, and the PM's own items (owner `you`). `mine` is the PM's own items. `init:<id>`
18
+ * is one initiative. Done shows the last 14 days under Active and under an initiative, however old the item's last event is
19
+ * (the layout's own window, lib/plan-tree.js DONE_WINDOW_MS); older Done items are counted, never listed. Under Mine, Done
20
+ * shows the PM's own items by the same window.
21
+ *
22
+ * WORK LANES ONLY. A document lane (scripts/lane-defs.js group 'document': prd, strategy, project-plan, decision-record,
23
+ * report) is a file, not a row: its initiative has no card, no option in the filter and no needs row. The group is read
24
+ * from the lane definitions, never from a list kept here.
25
+ *
26
+ * FACTORY. A work item that the factory materialized from a plan item carries `factory` (lib/factory-join.js): its state,
27
+ * its pull request, whether `pmos verify-work` saw it integrated. A card dragged to Done before that reads "Done · your
28
+ * call · not merged" (R7). Nothing here writes a factory record; the factory graph never writes the control graph.
29
+ *
30
+ * THE PAGE NEVER SEES A SESSION TOKEN (F5). Every `by` in the answer goes through actor.pageName, and so does every
31
+ * batch name. The signature is taken BEFORE the ledgers are read (the F2 fix): a line appended during the read leaves a
32
+ * signature older than the data at worst, which costs one more read and never hides a change.
33
+ */
34
+ const fs = require('fs');
35
+ const crypto = require('crypto');
36
+ const path = require('path');
37
+ const consoleState = require('./console-state');
38
+ const planItems = require('./plan-items.js');
39
+ const planTree = require('./plan-tree.js');
40
+ const placements = require('./placements.js');
41
+ const ledgers = require('./plan-ledgers.js');
42
+ const factoryJoin = require('./factory-join.js');
43
+ const planRules = require('./plan-rules.js');
44
+ const actor = require('./actor.js');
45
+
46
+ const DAY = 86400000;
47
+ const HORIZON_RANK = { now: 0, next: 1, later: 2 };
48
+ const STATES = ['todo', 'doing', 'review', 'done'];
49
+ const NOT_MERGED = planTree.NOT_MERGED_LABEL || 'Done · your call · not merged';
50
+ const TOKEN_TEXT_RE = /\b(sess-\d+)\.[0-9a-f]{8}\b/g;
51
+ const scrub = (t) => (typeof t === 'string' ? t.replace(TOKEN_TEXT_RE, '$1') : t);
52
+
53
+ function parseFilter(text) {
54
+ const f = text == null || text === '' ? 'active' : String(text);
55
+ if (f === 'active' || f === 'mine') return { kind: f };
56
+ const m = /^init:(.+)$/.exec(f);
57
+ if (m && planItems.INIT_RE.test(m[1])) return { kind: 'init', init: m[1] };
58
+ return null;
59
+ }
60
+ const canonical = (f) => (f.kind === 'init' ? 'init:' + f.init : f.kind);
61
+
62
+ /** The initiatives of `root` whose lane is a work lane, with the title their record gives. */
63
+ function workInitiatives(root) {
64
+ const loaded = consoleState.loadEngine();
65
+ if (!loaded.ok) throw new Error(loaded.error);
66
+ const eng = loaded.engine;
67
+ const out = [];
68
+ for (const id of eng.listInitiatives(root)) {
69
+ let pl;
70
+ try { pl = planTree.profileAndLane(root, id); } catch { continue; }
71
+ const def = pl.defs && pl.defs[pl.lane];
72
+ if (!def || def.group !== 'work') continue;
73
+ let title = id;
74
+ if (pl.record) {
75
+ try { title = String((eng.parseLeadingFrontmatter(fs.readFileSync(pl.record, 'utf8')) || {}).title || '').trim() || id; } catch { title = id; }
76
+ }
77
+ out.push({ id, title, lane: pl.lane, laneLabel: def.label || pl.lane });
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** One initiative's batches as the page names them: a repeated actor reads `agent:sess-3#2` (as the Breakdown names them). */
83
+ function pageBatches(batches) {
84
+ const seen = new Map();
85
+ return (batches || []).map((b) => {
86
+ const shown = actor.pageName(b.by);
87
+ const n = (seen.get(shown) || 0) + 1;
88
+ seen.set(shown, n);
89
+ const name = n === 1 ? shown : `${shown}#${n}`;
90
+ return { ...b, key: name, by: name };
91
+ });
92
+ }
93
+
94
+ /** An item as the page is sent it: no session token in any actor or text. */
95
+ function pageItem(it, extra) {
96
+ return {
97
+ ...it, ...extra,
98
+ createdBy: actor.pageName(it.createdBy), updatedBy: actor.pageName(it.updatedBy), movedBy: actor.pageName(it.movedBy),
99
+ note: scrub(it.note),
100
+ blocked: it.blocked ? { ...it.blocked, by: actor.pageName(it.blocked.by), reason: scrub(it.blocked.reason) } : it.blocked,
101
+ history: (it.history || []).map((h) => ({ ...h, by: actor.pageName(h.by), text: scrub(h.text) })),
102
+ };
103
+ }
104
+
105
+ /** The factory's view of one plan item: { item, state, pr, integrated, notMerged, label } or null. */
106
+ function factoryView(entry, it) {
107
+ if (it.factory && typeof it.factory === 'object') return it.factory; // lib/plan-tree.js already joined it
108
+ const w = entry && (entry.items || []).find((x) => x && x.plan_item === it.id);
109
+ if (!w) return null;
110
+ const notMerged = it.state === 'done' && w.integrated !== true;
111
+ return { item: w.id, state: w.state, pr: w.pr || null, integrated: w.integrated === true, notMerged, label: notMerged ? NOT_MERGED : null };
112
+ }
113
+
114
+ const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
115
+ const statOf = (f) => { try { const st = fs.statSync(f); return `${st.size}:${st.mtimeMs}`; } catch { return 'none'; } };
116
+
117
+ /** The grants a release press made, by target `<initiative>:<batch>`: read only, and only the lines that still hold. A grant
118
+ * holds while it is unexpired, or once it is used (the factory spent it). Expired and unused, it is gone: the PM may press again. */
119
+ function releaseGrants(root, now = Date.now()) {
120
+ const out = new Map();
121
+ let text = '';
122
+ try { text = fs.readFileSync(path.join(ledgers.runsDir(root), 'grants.jsonl'), 'utf8'); } catch { return out; }
123
+ const rows = [];
124
+ for (const raw of text.split('\n')) { if (!raw.trim()) continue; try { rows.push(JSON.parse(raw)); } catch { /* a torn line is the writer's to name */ } }
125
+ const used = new Set(rows.filter((r) => r && r.type === 'use' && typeof r.grant_id === 'string').map((r) => r.grant_id));
126
+ for (const r of rows) {
127
+ if (!r || r.type !== 'grant' || r.scope !== 'release' || typeof r.target !== 'string') continue;
128
+ if (used.has(r.id) || !(Date.parse(r.expires_at) <= now)) out.set(r.target, { grant_id: r.id });
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /** What the page's views read besides the two plan ledgers and the factory join: the initiative records (title, lane), the
134
+ * rules file (stall_days) and the grants ledger (a pressed Release card goes). One stat each, so a write by the CLI shows. */
135
+ function sideSig(root) {
136
+ const parts = [];
137
+ try {
138
+ const eng = consoleState.loadEngine();
139
+ if (eng.ok) for (const id of eng.engine.listInitiatives(root)) { const pl = planTree.profileAndLane(root, id); parts.push(id + '=' + (pl.record ? statOf(pl.record) : 'none') + ':' + pl.lane); }
140
+ } catch { parts.push('initiatives:unreadable'); }
141
+ try { const rp = planRules.rulesPath(root); parts.push('rules=' + (rp ? statOf(rp) : 'none')); } catch { parts.push('rules:unreadable'); }
142
+ try { parts.push('grants=' + statOf(path.join(ledgers.runsDir(root), 'grants.jsonl'))); } catch { parts.push('grants:unreadable'); }
143
+ return sha1(parts.join('|'));
144
+ }
145
+
146
+ /** The signature alone, for the page's watcher: the same hash boardFor puts in `sig`, taken without reading the items. */
147
+ function sigFor(w) {
148
+ const root = w.root;
149
+ let itemsSig; let placeSig;
150
+ try { itemsSig = planItems.ledgerSigs([{ id: w.id, root }], '')[w.id].items; } catch { itemsSig = 'unreadable'; }
151
+ try { placeSig = ledgers.ledgerSig(root, 'placements'); } catch { placeSig = 'unreadable'; }
152
+ const join = factoryJoin.joinFactory(root);
153
+ return sha1([itemsSig, placeSig, JSON.stringify(join.byInit || {}), String((join.flags || []).length), sideSig(root)].join('|'));
154
+ }
155
+
156
+ function boardFor(w, { filter, now = Date.now() } = {}) {
157
+ const f = parseFilter(filter);
158
+ if (!f) return { ok: false, status: 400, error: 'the filter is active, mine or init:<initiative>' };
159
+ const root = w.root;
160
+ // the signature first (F2): the ledgers it names are read below
161
+ let itemsSig; let placeSig;
162
+ try { itemsSig = planItems.ledgerSigs([{ id: w.id, root }], '')[w.id].items; } catch { itemsSig = 'unreadable'; }
163
+ try { placeSig = ledgers.ledgerSig(root, 'placements'); } catch { placeSig = 'unreadable'; }
164
+
165
+ const sideSigAt = sideSig(root);
166
+ const inits = workInitiatives(root);
167
+ if (f.kind === 'init' && !inits.some((i) => i.id === f.init)) {
168
+ return { ok: false, status: 404, error: `no initiative ${f.init} on the Board` };
169
+ }
170
+ const rules = planRules.readRules(root);
171
+ const place = placements.readPlacements(root, { now, rules, records: inits.map((i) => ({ id: i.id, lane: i.lane })) });
172
+ const join = factoryJoin.joinFactory(root);
173
+ const fold = planItems.readFold(root, { now });
174
+ const stallMs = rules.stall_days * DAY;
175
+
176
+ const rows = inits.map((i) => {
177
+ const p = place.byInit && place.byInit[i.id];
178
+ return { ...i, horizon: placements.horizonOf(place, i.id), rank: p && Number.isFinite(p.rank) ? p.rank : 0 };
179
+ }).sort((a, b) => (HORIZON_RANK[a.horizon] - HORIZON_RANK[b.horizon]) || (a.rank - b.rank) || a.id.localeCompare(b.id));
180
+
181
+ const wanted = (it, row) => {
182
+ if (f.kind === 'init') return it.initiative === f.init;
183
+ if (it.owner === 'you') return true;
184
+ if (f.kind === 'mine') return false;
185
+ if (it.state === 'done') return true; // plan-tree's 14-day window lists the recent ones; the rest are counted
186
+ if (row.horizon === 'now') return true;
187
+ return Date.parse(it.updatedAt) >= now - stallMs;
188
+ };
189
+
190
+ const items = {};
191
+ const board = { todo: [], doing: [], review: [], done: [], doneOlder: 0 };
192
+ const initiatives = [];
193
+ for (const row of rows) {
194
+ const lay = planTree.layout({ root, initiative: row.id, fold, ladder: null, now });
195
+ const entry = factoryJoin.factoryOf(join, row.id);
196
+ const keep = new Set(Object.keys(lay.items).filter((id) => wanted(lay.items[id], row)));
197
+ let listed = 0;
198
+ for (const st of STATES) for (const id of lay.board[st]) {
199
+ if (!keep.has(id)) continue;
200
+ listed++;
201
+ items[id] = pageItem(lay.items[id], { stageLabel: (lay.stages.find((s) => s.id === lay.items[id].stage) || {}).label || lay.items[id].stage, factory: factoryView(entry, lay.items[id]) });
202
+ board[st].push(id);
203
+ }
204
+ const doneAll = [...keep].filter((id) => lay.items[id].state === 'done').length;
205
+ board.doneOlder += doneAll - lay.board.done.filter((id) => keep.has(id)).length;
206
+ initiatives.push({
207
+ id: row.id, title: row.title, lane: row.lane, laneLabel: row.laneLabel, horizon: row.horizon, rank: row.rank,
208
+ stages: lay.stages.map((s) => ({ id: s.id, label: s.label })), batches: pageBatches(lay.batches), shown: listed,
209
+ });
210
+ }
211
+ const counts = { todo: board.todo.length, doing: board.doing.length, review: board.review.length, done: board.done.length, doneOlder: board.doneOlder };
212
+ const sig = sha1([itemsSig, placeSig, JSON.stringify(join.byInit || {}), String((join.flags || []).length), sideSigAt].join('|'));
213
+ const granted = releaseGrants(root, now);
214
+ // the ready batches a person can release: one card each (D86 point 2). Shown under every filter, for a work initiative.
215
+ const releases = [];
216
+ for (const row of rows) {
217
+ if (f.kind === 'init' && row.id !== f.init) continue;
218
+ const entry = factoryJoin.factoryOf(join, row.id);
219
+ for (const b of (entry && entry.batches) || []) {
220
+ if (!b || !b.ready || granted.has(`${row.id}:${b.id}`)) continue;
221
+ const ws = (b.items || []).map((wid) => {
222
+ const wi = (entry.items || []).find((x) => x && x.id === wid) || {};
223
+ const pi = wi.plan_item && items[wi.plan_item];
224
+ return { id: wid, plan_item: wi.plan_item || null, title: pi ? pi.title : wid, state: wi.state || null, pr: wi.pr || null, integrated: wi.integrated === true };
225
+ });
226
+ releases.push({ initiative: row.id, initiativeTitle: row.title, batch: b.id, items: ws });
227
+ }
228
+ }
229
+ return { ok: true, filter: canonical(f), now: new Date(now).toISOString(), sig, initiatives, items, board, counts, releases };
230
+ }
231
+
232
+ module.exports = { boardFor, sigFor, sideSig, releaseGrants, parseFilter, workInitiatives, factoryView, STATES, NOT_MERGED };