create-pmos 0.134.0 → 0.136.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 (211) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +2 -2
  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/payload/state/log.md +1 -1
  7. package/workspace/bin/notes-replay.js +235 -0
  8. package/workspace/c3-briefing-test.js +130 -0
  9. package/workspace/c3-foundation-test.js +159 -0
  10. package/workspace/c3-ib-items-test.js +105 -0
  11. package/workspace/c3-ib-more-test.js +120 -0
  12. package/workspace/c3-ib-triage-backlog-test.js +119 -0
  13. package/workspace/c3-ini-harness.js +66 -0
  14. package/workspace/c3-initiatives-test.js +140 -0
  15. package/workspace/c3-one-home-test.js +14 -1
  16. package/workspace/c3-reports-test.js +57 -18
  17. package/workspace/c3-settings-test.js +139 -0
  18. package/workspace/c3-work-test.js +42 -10
  19. package/workspace/fixtures/one-home-baseline.json +1 -9
  20. package/workspace/guide/briefing-cards.md +15 -3
  21. package/workspace/guide/briefing.md +12 -8
  22. package/workspace/guide/calendar-popup.md +2 -2
  23. package/workspace/guide/console-calendar.md +39 -10
  24. package/workspace/guide/index.md +1 -0
  25. package/workspace/guide/initiative-boards.md +65 -0
  26. package/workspace/guide/jira.md +3 -1
  27. package/workspace/guide/layout.md +2 -0
  28. package/workspace/guide/my-work.md +5 -3
  29. package/workspace/guide/notes.md +16 -1
  30. package/workspace/guide/reports-dashboards.md +29 -5
  31. package/workspace/guide/settings.md +68 -0
  32. package/workspace/guide/welcome.md +10 -10
  33. package/workspace/lib/calendar-cli.js +3 -2
  34. package/workspace/lib/calendar-layers.js +21 -10
  35. package/workspace/lib/calendar-tidy.js +230 -0
  36. package/workspace/lib/engine/boards.js +542 -0
  37. package/workspace/lib/engine/breakdown-ops.js +180 -0
  38. package/workspace/lib/engine/calendar-import.js +84 -0
  39. package/workspace/lib/engine/calendar-model.js +185 -0
  40. package/workspace/lib/engine/card-state.js +92 -0
  41. package/workspace/lib/engine/derived/derive.js +92 -0
  42. package/workspace/lib/engine/derived/plan.js +3 -0
  43. package/workspace/lib/engine/derived/trust.js +3 -0
  44. package/workspace/lib/engine/doc-health.js +142 -0
  45. package/workspace/lib/engine/doc-sheet.js +75 -0
  46. package/workspace/lib/engine/intake.js +78 -0
  47. package/workspace/lib/engine/outbox.js +86 -0
  48. package/workspace/lib/engine/plan-forecast.js +342 -0
  49. package/workspace/lib/engine/promises.js +29 -0
  50. package/workspace/lib/engine/records/index.js +114 -0
  51. package/workspace/lib/engine/releases.js +96 -0
  52. package/workspace/lib/engine/replies.js +99 -0
  53. package/workspace/lib/engine/reply-contract/reply-contract.js +372 -0
  54. package/workspace/lib/engine/report-families.js +175 -0
  55. package/workspace/lib/engine/roadmap-ops.js +150 -0
  56. package/workspace/lib/engine/sessions-list.js +27 -0
  57. package/workspace/lib/engine/signal-rules.js +95 -0
  58. package/workspace/lib/engine/signals/cards.js +57 -0
  59. package/workspace/lib/engine/signals/detectors.js +370 -0
  60. package/workspace/lib/engine/signals/facts-cli.js +8 -0
  61. package/workspace/lib/engine/signals/facts.js +197 -0
  62. package/workspace/lib/engine/signals/index.js +33 -0
  63. package/workspace/lib/engine/signals/kinds.js +110 -0
  64. package/workspace/lib/engine/signals/layout-paths.js +44 -0
  65. package/workspace/lib/engine/signals/options.js +24 -0
  66. package/workspace/lib/engine/signals/score.js +72 -0
  67. package/workspace/lib/engine/signals/select.js +47 -0
  68. package/workspace/lib/engine/step-planner.js +65 -0
  69. package/workspace/lib/engine/trust/claim-rules.json +93 -0
  70. package/workspace/lib/engine/trust/import-calendar.js +21 -0
  71. package/workspace/lib/engine/trust/import-guard.js +36 -0
  72. package/workspace/lib/engine/trust/ledger.js +53 -0
  73. package/workspace/lib/engine/trust/outbox.js +45 -0
  74. package/workspace/lib/engine/trust/plan.js +5 -0
  75. package/workspace/lib/engine/trust/trust.js +21 -0
  76. package/workspace/lib/engine/truth-table.js +57 -0
  77. package/workspace/lib/engine/workspace-lib.js +24 -0
  78. package/workspace/lib/factory-join.js +1 -1
  79. package/workspace/lib/facts-async.js +4 -4
  80. package/workspace/lib/headless.js +95 -0
  81. package/workspace/lib/import-overlay.js +2 -1
  82. package/workspace/lib/jira-status.js +30 -3
  83. package/workspace/lib/notes-capture.js +9 -1
  84. package/workspace/lib/notes-final.js +38 -13
  85. package/workspace/lib/notes-replay.js +125 -0
  86. package/workspace/lib/notes-screen.js +180 -0
  87. package/workspace/lib/notes-whisper.js +10 -3
  88. package/workspace/lib/outbox-store.js +10 -6
  89. package/workspace/lib/pmos-verbs.js +3 -1
  90. package/workspace/lib/proactive-cli.js +7 -0
  91. package/workspace/lib/proactive.js +15 -2
  92. package/workspace/lib/records-dir.js +29 -0
  93. package/workspace/lib/repo-script.js +36 -0
  94. package/workspace/lib/report-dashboards.js +5 -2
  95. package/workspace/lib/report-frame.js +122 -0
  96. package/workspace/lib/report-schedules.js +113 -0
  97. package/workspace/lib/route-modules.js +9 -2
  98. package/workspace/lib/routes-boards.js +734 -0
  99. package/workspace/lib/routes-brief-page.js +13 -4
  100. package/workspace/lib/routes-briefing.js +22 -11
  101. package/workspace/lib/routes-c3-init.js +9 -8
  102. package/workspace/lib/routes-c3-life.js +2 -1
  103. package/workspace/lib/routes-c3-reports.js +99 -15
  104. package/workspace/lib/routes-c3-strategy.js +7 -5
  105. package/workspace/lib/routes-c3-timeline.js +3 -2
  106. package/workspace/lib/routes-calendar-import.js +3 -2
  107. package/workspace/lib/routes-calendar-views.js +9 -4
  108. package/workspace/lib/routes-doc-health.js +2 -1
  109. package/workspace/lib/routes-doc-sheet.js +2 -1
  110. package/workspace/lib/routes-forecast.js +2 -1
  111. package/workspace/lib/routes-hooks.js +3 -2
  112. package/workspace/lib/routes-initiatives.js +2 -1
  113. package/workspace/lib/routes-intake.js +2 -1
  114. package/workspace/lib/routes-notes.js +55 -9
  115. package/workspace/lib/routes-outbox.js +23 -3
  116. package/workspace/lib/routes-pm-items.js +19 -6
  117. package/workspace/lib/routes-records.js +10 -7
  118. package/workspace/lib/routes-releases.js +2 -1
  119. package/workspace/lib/routes-report-facts.js +4 -3
  120. package/workspace/lib/routes-report-families.js +2 -1
  121. package/workspace/lib/routes-roadmap-ops.js +17 -14
  122. package/workspace/lib/routes-runs.js +6 -5
  123. package/workspace/lib/routes-settings.js +51 -0
  124. package/workspace/lib/routes-signals.js +3 -2
  125. package/workspace/lib/routes-stage-tools.js +220 -0
  126. package/workspace/lib/run-answers.js +2 -1
  127. package/workspace/lib/settings-store.js +226 -0
  128. package/workspace/lib/stage-tools.js +208 -0
  129. package/workspace/lib/vendor/scripts/signals/layout-paths.js +2 -1
  130. package/workspace/lib/vendor/scripts/workspace-lib.js +24 -0
  131. package/workspace/lib/work-evidence.js +73 -0
  132. package/workspace/package-lock.json +2 -2
  133. package/workspace/package.json +1 -1
  134. package/workspace/public/pm/briefing-deck.js +63 -17
  135. package/workspace/public/pm/briefing-model.js +36 -7
  136. package/workspace/public/pm/c3-ambient.js +100 -0
  137. package/workspace/public/pm/c3-brief-needs.js +26 -6
  138. package/workspace/public/pm/c3-brief-pulse.js +12 -4
  139. package/workspace/public/pm/c3-brief-today.js +56 -21
  140. package/workspace/public/pm/c3-brief.js +2 -1
  141. package/workspace/public/pm/c3-calendar.js +161 -42
  142. package/workspace/public/pm/c3-calpop.js +5 -5
  143. package/workspace/public/pm/c3-demo-boards.js +649 -0
  144. package/workspace/public/pm/c3-demo-brief-page.js +1 -1
  145. package/workspace/public/pm/c3-demo-cal4.js +3 -2
  146. package/workspace/public/pm/c3-demo-cal5.js +52 -0
  147. package/workspace/public/pm/c3-demo-data.js +17 -27
  148. package/workspace/public/pm/c3-demo-rep3.js +46 -5
  149. package/workspace/public/pm/c3-demo.js +2 -1
  150. package/workspace/public/pm/c3-dock.js +1 -1
  151. package/workspace/public/pm/c3-ib-docs.js +80 -0
  152. package/workspace/public/pm/c3-ib-initiatives.js +134 -0
  153. package/workspace/public/pm/c3-ib-items.js +201 -0
  154. package/workspace/public/pm/c3-ib-releases.js +51 -0
  155. package/workspace/public/pm/c3-ib-sprints.js +55 -0
  156. package/workspace/public/pm/c3-ib-triage.js +178 -0
  157. package/workspace/public/pm/c3-ii-tabs.js +240 -0
  158. package/workspace/public/pm/c3-ini-records.js +197 -0
  159. package/workspace/public/pm/c3-ini-timeline.js +278 -0
  160. package/workspace/public/pm/c3-ini-ui.js +229 -0
  161. package/workspace/public/pm/c3-ini-work.js +139 -0
  162. package/workspace/public/pm/c3-initiatives.css +218 -0
  163. package/workspace/public/pm/c3-initiatives.js +741 -0
  164. package/workspace/public/pm/c3-plan.js +2 -2
  165. package/workspace/public/pm/c3-reports.css +47 -47
  166. package/workspace/public/pm/c3-reports.js +223 -185
  167. package/workspace/public/pm/c3-settings.css +74 -0
  168. package/workspace/public/pm/c3-settings.js +703 -0
  169. package/workspace/public/pm/c3-shell.js +102 -36
  170. package/workspace/public/pm/c3-terminal-icons.js +2 -2
  171. package/workspace/public/pm/c3-terminal.js +2 -2
  172. package/workspace/public/pm/calendar-views.js +1 -1
  173. package/workspace/public/pm/components.css +319 -213
  174. package/workspace/public/pm/console3.css +99 -23
  175. package/workspace/public/pm/dest-notes.js +26 -4
  176. package/workspace/public/pm/notes-screen-capture.js +165 -0
  177. package/workspace/public/pm/notes-screen-view.js +98 -0
  178. package/workspace/public/pm/panel-trees.js +5 -2
  179. package/workspace/public/pm/pm-work-view.js +233 -146
  180. package/workspace/public/pm/shell-bell.js +3 -2
  181. package/workspace/public/pm/shell-recording.js +29 -8
  182. package/workspace/public/pm/tokens.css +2 -2
  183. package/workspace/public/pm/view-notes.js +46 -9
  184. package/workspace/public/pm/welcome.js +197 -89
  185. package/workspace/public/welcome.html +6 -4
  186. package/workspace/server.js +105 -6
  187. package/workspace/skills/day-check.skill +1 -1
  188. package/workspace/skills/jira-sync.skill +6 -4
  189. package/workspace/skills/morning-brief.skill +1 -1
  190. package/workspace/test-files.json +12 -3
  191. package/workspace/test-lists/c3-ib-items.json +9 -0
  192. package/workspace/test-lists/c3-ib-more.json +9 -0
  193. package/workspace/test-lists/c3-ib-triage-backlog.json +9 -0
  194. package/workspace/test-lists/c3-initiative-boards.json +6 -0
  195. package/workspace/test-lists/calendar-tidy-up.json +6 -0
  196. package/workspace/test-lists/console3-briefing.json +9 -0
  197. package/workspace/test-lists/console3-foundation.json +9 -0
  198. package/workspace/test-lists/console3-reports.json +3 -3
  199. package/workspace/test-lists/meeting-notes.json +10 -4
  200. package/workspace/test-lists/workspace-headless-jobs.json +5 -0
  201. package/workspace/test-lists/workspace-package-script-closure.json +6 -0
  202. package/workspace/lib/vendor/workspace/lib/actor.js +0 -91
  203. package/workspace/lib/vendor/workspace/lib/board-cache.js +0 -156
  204. package/workspace/lib/vendor/workspace/lib/console-state.js +0 -295
  205. package/workspace/lib/vendor/workspace/lib/factory-join.js +0 -158
  206. package/workspace/lib/vendor/workspace/lib/git-info.js +0 -306
  207. package/workspace/lib/vendor/workspace/lib/plan-items.js +0 -922
  208. package/workspace/lib/vendor/workspace/lib/plan-tree.js +0 -419
  209. package/workspace/lib/vendor/workspace/lib/review-gate.js +0 -39
  210. package/workspace/lib/vendor/workspace/lib/sink.js +0 -321
  211. package/workspace/lib/vendor/workspace/lib/state-root.js +0 -587
@@ -0,0 +1,542 @@
1
+ 'use strict';
2
+ /* boards.js — the model of console3 Initiatives boards and workflows (c3-initiative-boards; PRD "Model", "State").
3
+ * Pure: no file, no network, no clock read unless the caller passes none. workspace/lib/routes-boards.js does the I/O.
4
+ *
5
+ * CATEGORIES · WORKFLOW_TEMPLATES · TOOLS · SELF_LADDER · READING_CAT
6
+ * templateWorkflow(name) → a fresh copy of a template's stages [{ name, cat, hint }]
7
+ * isTemplateEdited(template, workflow) → true when the stages differ from the template's (names or categories)
8
+ * fold(events) → { boards, moves, assigns, asks, stages, events }
9
+ * readingOf(nineStages, { accepted }) → the nine-stage key the reading names (scripts/nine-stages.js output)
10
+ * deriveStage(board, reading) → the stage name a reading maps to on this board's workflow
11
+ * stageOf(board, id, reading, folded) → { stage, source: 'move'|'derived' }
12
+ * moveWarnings(board, row, from, to, facts) → ['Skipped Plan', 'No spec yet', 'No anchor']
13
+ * cardWarnings(board, row, facts) → the warnings a card shows where it stands now (no Skipped)
14
+ * applyWorkflowEdit(board, stages, moveRemoved, rowsByStage) → { ok, workflow, renamed, removed, moved } | { ok:false, status, error }
15
+ * filterRows(rows, query) → { total, offset, limit, rows, facets }
16
+ * health(rows) → 'ok' | 'warn' | 'bad'
17
+ * counts(rows, workflow) → { total, open, byCat, byStage, byStatus, risk, late }
18
+ * sprintsOf(snapshots, onBoard) → { source, readAt, sprints }
19
+ * INI_TEMPLATES · isBuildStage · templateBuilds · boardSettings(board) the per-initiative workflows (console3-initiatives-parity)
20
+ * workflowOf(board, id, folded) → the stages an initiative runs: its own (ini-workflow), else the board's
21
+ * applyIniWorkflow(board, wfNow, stage, template, stages, moveTo) → { ok, template, workflow, moved } | { ok:false, status, error }
22
+ * taskOf(folded, id) · TASK_STATES · shiftDay(iso, days) PMOS tasks on a board
23
+ * itemStatus(status, category) · taskItem(task) · rollupItems(items) work items (PMOS tasks and Jira items) in one shape
24
+ *
25
+ * THE LEDGER. One append-only file per repository, <state dir>/boards.jsonl. A line is { at, by, kind, board, ... }:
26
+ * board { op: 'create'|'settings', name?, domain?, template?, workflow?, sprints?, tracker? }
27
+ * workflow { stages: [{ name, cat, hint }], renamed: { old: new }, moved: [{ id, from, to }], sprints? }
28
+ * move { id, from, to, why, warnings }
29
+ * assign { id, to } (`board` is the board it left, or null)
30
+ * ask { ask, line, source, draft }
31
+ * ask-accept { ask, initiative, stage }
32
+ * ask-decline { ask, why }
33
+ * ask-more { ask, note } the asker is prompted for more detail; the ask stays open
34
+ * board op 'create'|'settings' may also carry { buildDeliver, jira, offer, defTpl } (Build and deliver, true when missing; the Jira project key, the templates
35
+ * new initiatives may pick, the template they start with)
36
+ * ini-workflow { id, template, stages: [{ name, cat, hint }], moved: { from, to } | null } one initiative's own workflow
37
+ * task { task, type: Task|Epic, initiative, title, epic, owner, state, start, due } a PMOS task (or PMOS epic) on the board
38
+ * task-update { task, state?, start?, due?, why } a status move or a reschedule
39
+ * Nothing is deleted. A line that is not an object with a known kind is skipped.
40
+ *
41
+ * STAGE OF AN INITIATIVE on a team board: the last move recorded for it on that board (a workflow edit's `moved` and an
42
+ * ask-accept count as moves; a rename carries the move along). With none, it is derived from the nine-stage reading:
43
+ * discover|ideate → first Discover stage; strategize|prioritize|plan → first Plan; build|deliver → first Do;
44
+ * monitor|evaluate → first Learn. A workflow with no stage in that category uses the nearest earlier category, else
45
+ * its first stage.
46
+ */
47
+
48
+ const CATEGORIES = Object.freeze(['Discover', 'Plan', 'Do', 'Learn']);
49
+
50
+ const st = (name, cat, hint) => Object.freeze({ name, cat, hint });
51
+ const WORKFLOW_TEMPLATES = Object.freeze({
52
+ product: Object.freeze([
53
+ st('Discover', 'Discover', 'Find the problem worth solving and the evidence for it.'),
54
+ st('Plan', 'Plan', 'Write the spec, agree the done-criteria and place it on the roadmap.'),
55
+ st('Build & Deliver', 'Do', 'Build, test and release.'),
56
+ st('Monitor & Evaluate', 'Learn', 'Read the outcome against the anchor and decide what is next.'),
57
+ ]),
58
+ project: Object.freeze([
59
+ st('Plan', 'Plan', 'Agree the scope, the owner and the dates.'),
60
+ st('Execute', 'Do', 'Do the work and track it.'),
61
+ st('Close', 'Learn', 'Confirm the result and record what was learned.'),
62
+ ]),
63
+ simple: Object.freeze([
64
+ st('To do', 'Plan', 'Not started.'),
65
+ st('Doing', 'Do', 'In progress.'),
66
+ st('Done', 'Learn', 'Finished.'),
67
+ ]),
68
+ });
69
+
70
+ /* The workflows an initiative can run (console3-initiatives-parity). product, project and simple are the board templates;
71
+ * fix, research and content are initiative-only. A template with a Build or Deliver stage needs a board with Build and
72
+ * deliver on. `kind` picks the stage tools (product, project, simple). */
73
+ const INI_TEMPLATES = Object.freeze({
74
+ product: Object.freeze({ name: 'Product', kind: 'product', use: 'Product work that ships: discovery through to evaluating what shipped.', stages: WORKFLOW_TEMPLATES.product }),
75
+ fix: Object.freeze({ name: 'Quick fix', kind: 'product', use: 'A small, known change: plan it, build it, ship it, check it.', stages: Object.freeze([st('Plan', 'Plan', ''), st('Build & Deliver', 'Do', ''), st('Evaluate', 'Learn', '')]) }),
76
+ research: Object.freeze({ name: 'Research only', kind: 'product', use: 'Learn before committing. Nothing gets built in this initiative.', stages: Object.freeze([st('Discover', 'Discover', ''), st('Plan', 'Plan', ''), st('Evaluate', 'Learn', '')]) }),
77
+ content: Object.freeze({ name: 'Content and launch', kind: 'project', use: 'Pages, campaigns and launches that a team or an agency produces.', stages: Object.freeze([st('Discover', 'Discover', ''), st('Plan', 'Plan', ''), st('Produce', 'Do', ''), st('Launch', 'Do', ''), st('Evaluate', 'Learn', '')]) }),
78
+ project: Object.freeze({ name: 'Project', kind: 'project', use: 'Someone who only manages a project: plan it, run it, close it.', stages: WORKFLOW_TEMPLATES.project }),
79
+ simple: Object.freeze({ name: 'Simple', kind: 'simple', use: 'A plain to-do flow for small or personal work.', stages: WORKFLOW_TEMPLATES.simple }),
80
+ });
81
+ const isBuildStage = (s) => /^(build|deliver)/i.test(String((s && s.name) || s || ''));
82
+ const templateBuilds = (k) => !!INI_TEMPLATES[k] && INI_TEMPLATES[k].stages.some(isBuildStage);
83
+ /** A board's Build and deliver settings, with the defaults of a board that never set them: buildDeliver is true when the
84
+ * ledger line does not carry it (the stage-records thread reads the same field the same way), every template the board may run is offered, and new initiatives start with
85
+ * the board's own template. */
86
+ function boardSettings(board) {
87
+ const b = board || {};
88
+ const build = typeof b.buildDeliver === 'boolean' ? b.buildDeliver : true;
89
+ const allowed = Object.keys(INI_TEMPLATES).filter((k) => build || !templateBuilds(k));
90
+ let offer = Array.isArray(b.offer) ? b.offer.filter((k) => allowed.includes(k)) : allowed;
91
+ if (!offer.length) offer = allowed;
92
+ const tpl = INI_TEMPLATES[b.template] ? b.template : 'product';
93
+ let defTpl = typeof b.defTpl === 'string' && offer.includes(b.defTpl) ? b.defTpl : (offer.includes(tpl) ? tpl : offer[0]);
94
+ if (!defTpl) defTpl = 'simple';
95
+ const jira = build ? (typeof b.jira === 'string' && b.jira ? b.jira : (/^Jira · (.+)$/.exec(b.tracker || '') || [])[1] || null) : null;
96
+ return { build, jira, offer, defTpl };
97
+ }
98
+
99
+ /* The PMOS-self board: the nine stage names of scripts/nine-stages.js, read-only, each in its category. */
100
+ const READING_CAT = Object.freeze({
101
+ discover: 'Discover', ideate: 'Discover',
102
+ strategize: 'Plan', prioritize: 'Plan', plan: 'Plan',
103
+ build: 'Do', deliver: 'Do',
104
+ monitor: 'Learn', evaluate: 'Learn',
105
+ });
106
+ const NINE = ['discover', 'ideate', 'strategize', 'prioritize', 'plan', 'build', 'deliver', 'monitor', 'evaluate'];
107
+ const NINE_LABEL = { discover: 'Discover', ideate: 'Ideate', strategize: 'Strategize', prioritize: 'Prioritize', plan: 'Plan', build: 'Build', deliver: 'Deliver', monitor: 'Monitor', evaluate: 'Evaluate' };
108
+ const SELF_LADDER = Object.freeze(NINE.map((k) => st(NINE_LABEL[k], READING_CAT[k], 'Derived by scripts/workflow-state.js; not moved by hand.')));
109
+
110
+ /* The stage tools of each category, per template: the initiative page shows these first and the rest behind
111
+ * "More tools". `id` names the page section or tool the UI opens. */
112
+ const T = (id, label) => Object.freeze({ id, label });
113
+ const TOOLS = Object.freeze({
114
+ product: {
115
+ Discover: [T('ideas', 'Ideas'), T('hypotheses', 'Hypotheses'), T('notes', 'Notes')],
116
+ Plan: [T('prd', 'PRD'), T('rubric', 'Done-criteria'), T('breakdown', 'Breakdown'), T('placement', 'Roadmap place')],
117
+ Do: [T('breakdown', 'Breakdown'), T('tickets', 'Tickets'), T('releases', 'Releases')],
118
+ Learn: [T('measurements', 'Measurements'), T('evaluation', 'Evaluation'), T('history', 'History')],
119
+ },
120
+ project: {
121
+ Discover: [T('notes', 'Notes')],
122
+ Plan: [T('brief', 'Brief'), T('breakdown', 'Breakdown'), T('timeline', 'Timeline')],
123
+ Do: [T('breakdown', 'Breakdown'), T('tickets', 'Tickets'), T('timeline', 'Timeline')],
124
+ Learn: [T('notes', 'Notes'), T('history', 'History')],
125
+ },
126
+ simple: {
127
+ Discover: [T('notes', 'Notes')],
128
+ Plan: [T('notes', 'Notes')],
129
+ Do: [T('breakdown', 'Breakdown')],
130
+ Learn: [T('history', 'History')],
131
+ },
132
+ pmos: {
133
+ Discover: [T('ideas', 'Ideas'), T('notes', 'Notes')],
134
+ Plan: [T('prd', 'PRD'), T('rubric', 'Done-criteria'), T('breakdown', 'Breakdown')],
135
+ Do: [T('breakdown', 'Breakdown'), T('tickets', 'Tickets'), T('releases', 'Releases')],
136
+ Learn: [T('evaluation', 'Evaluation'), T('measurements', 'Measurements'), T('history', 'History')],
137
+ },
138
+ });
139
+
140
+ const STATUSES = Object.freeze(['on track', 'at risk', 'late', 'done']);
141
+ const VIEWS = Object.freeze(['now', 'mine', 'risk', 'noowner', 'all']);
142
+ const KINDS = new Set(['board', 'workflow', 'move', 'assign', 'ask', 'ask-accept', 'ask-decline', 'ask-more', 'ini-workflow', 'task', 'task-update']);
143
+ const TASK_STATES = Object.freeze(['To do', 'In progress', 'In review', 'Done']);
144
+
145
+ const copyStages = (stages) => stages.map((s) => ({ name: s.name, cat: s.cat, hint: s.hint || '' }));
146
+ function templateWorkflow(name) {
147
+ const t = WORKFLOW_TEMPLATES[name];
148
+ return t ? copyStages(t) : null;
149
+ }
150
+ function isTemplateEdited(template, workflow) {
151
+ const t = WORKFLOW_TEMPLATES[template];
152
+ if (!t || !Array.isArray(workflow)) return false;
153
+ if (t.length !== workflow.length) return true;
154
+ return t.some((s, i) => s.name !== workflow[i].name || s.cat !== workflow[i].cat);
155
+ }
156
+
157
+ const key = (board, id) => `${board}\u0000${id}`;
158
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
159
+
160
+ /** Fold the ledger lines (objects or JSON strings), oldest first. */
161
+ function fold(events) {
162
+ const boards = new Map(); // board id → { id, name?, domain?, template?, workflow?, sprints?, tracker?, created }
163
+ const moves = []; // every move-like event, oldest first: { at, by, board, id, from, to, why, warnings, via }
164
+ const assigns = new Map(); // initiative id → board id
165
+ const asks = new Map(); // ask id → { id, board, line, source, draft, at, by, state, initiative, stage, why, closedAt }
166
+ const stages = new Map(); // key(board, id) → stage name (the last move)
167
+ const iniWorkflows = new Map(); // key(board, id) → { template, stages } (the initiative's own workflow)
168
+ const tasks = new Map(); // task id → { id, board, initiative, title, epic, owner, state, start, due, at, by, history }
169
+ const list = [];
170
+ for (let e of Array.isArray(events) ? events : []) {
171
+ if (typeof e === 'string') { try { e = JSON.parse(e); } catch { continue; } }
172
+ if (!isObj(e) || !KINDS.has(e.kind)) continue;
173
+ list.push(e);
174
+ const b = typeof e.board === 'string' ? e.board : null;
175
+ if (e.kind === 'board' && b) {
176
+ const cur = boards.get(b) || { id: b, created: null };
177
+ if (e.op === 'create' && !cur.created) cur.created = { at: e.at || null, by: e.by || null };
178
+ for (const k of ['name', 'domain', 'template', 'sprints', 'tracker', 'buildDeliver', 'jira', 'offer', 'defTpl']) if (k in e) cur[k] = e[k];
179
+ if (Array.isArray(e.workflow)) cur.workflow = copyStages(e.workflow);
180
+ boards.set(b, cur);
181
+ } else if (e.kind === 'workflow' && b && Array.isArray(e.stages)) {
182
+ const cur = boards.get(b) || { id: b, created: null };
183
+ cur.workflow = copyStages(e.stages);
184
+ if ('sprints' in e) cur.sprints = !!e.sprints;
185
+ boards.set(b, cur);
186
+ const renamed = isObj(e.renamed) ? e.renamed : {};
187
+ if (Object.keys(renamed).length) {
188
+ for (const [k, v] of stages) if (k.startsWith(b + '\u0000') && renamed[v]) stages.set(k, renamed[v]);
189
+ }
190
+ for (const m of Array.isArray(e.moved) ? e.moved : []) {
191
+ if (!m || typeof m.id !== 'string' || typeof m.to !== 'string') continue;
192
+ stages.set(key(b, m.id), m.to);
193
+ moves.push({ at: e.at || null, by: e.by || null, board: b, id: m.id, from: m.from || null, to: m.to, why: 'stage removed', warnings: [], via: 'workflow' });
194
+ }
195
+ } else if (e.kind === 'move' && b && typeof e.id === 'string' && typeof e.to === 'string') {
196
+ stages.set(key(b, e.id), e.to);
197
+ moves.push({ at: e.at || null, by: e.by || null, board: b, id: e.id, from: e.from || null, to: e.to, why: e.why || '', warnings: Array.isArray(e.warnings) ? e.warnings : [], via: 'move' });
198
+ } else if (e.kind === 'assign' && typeof e.id === 'string' && typeof e.to === 'string') {
199
+ assigns.set(e.id, e.to);
200
+ } else if (e.kind === 'ask' && b && typeof e.ask === 'string' && !asks.has(e.ask)) {
201
+ asks.set(e.ask, { id: e.ask, board: b, line: String(e.line || ''), source: e.source || null, draft: e.draft || null, at: e.at || null, by: e.by || null, state: 'open', initiative: null, stage: null, why: null, closedAt: null });
202
+ } else if (e.kind === 'ask-accept' && typeof e.ask === 'string' && asks.has(e.ask)) {
203
+ const a = asks.get(e.ask);
204
+ if (a.state !== 'open') continue;
205
+ Object.assign(a, { state: 'accepted', initiative: e.initiative || null, stage: e.stage || null, closedAt: e.at || null });
206
+ if (typeof e.initiative === 'string') {
207
+ assigns.set(e.initiative, a.board);
208
+ if (typeof e.stage === 'string') {
209
+ stages.set(key(a.board, e.initiative), e.stage);
210
+ moves.push({ at: e.at || null, by: e.by || null, board: a.board, id: e.initiative, from: null, to: e.stage, why: 'accepted from triage', warnings: [], via: 'ask-accept' });
211
+ }
212
+ }
213
+ } else if (e.kind === 'ask-more' && typeof e.ask === 'string' && asks.has(e.ask)) {
214
+ const a = asks.get(e.ask);
215
+ if (a.state === 'open') { a.needMore = true; a.moreNote = typeof e.note === 'string' ? e.note : ''; a.moreAt = e.at || null; }
216
+ } else if (e.kind === 'ini-workflow' && b && typeof e.id === 'string' && Array.isArray(e.stages) && e.stages.length) {
217
+ const k = key(b, e.id);
218
+ iniWorkflows.set(k, { template: typeof e.template === 'string' ? e.template : null, stages: copyStages(e.stages) });
219
+ const renamed = isObj(e.renamed) ? e.renamed : {};
220
+ if (stages.has(k) && renamed[stages.get(k)]) stages.set(k, renamed[stages.get(k)]);
221
+ if (isObj(e.moved) && typeof e.moved.to === 'string') {
222
+ stages.set(k, e.moved.to);
223
+ moves.push({ at: e.at || null, by: e.by || null, board: b, id: e.id, from: e.moved.from || null, to: e.moved.to, why: 'stage removed from its workflow', warnings: [], via: 'ini-workflow' });
224
+ }
225
+ } else if (e.kind === 'task' && b && typeof e.task === 'string' && typeof e.initiative === 'string' && !tasks.has(e.task)) {
226
+ tasks.set(e.task, { id: e.task, board: b, initiative: e.initiative, kind: e.type === 'Epic' ? 'Epic' : 'Task', title: String(e.title || ''), epic: typeof e.epic === 'string' && e.epic ? e.epic : null,
227
+ owner: typeof e.owner === 'string' && e.owner ? e.owner : null, state: TASK_STATES.includes(e.state) ? e.state : 'To do',
228
+ start: typeof e.start === 'string' ? e.start : null, due: typeof e.due === 'string' ? e.due : null, at: e.at || null, by: e.by || null, history: [] });
229
+ } else if (e.kind === 'task-update' && typeof e.task === 'string' && tasks.has(e.task)) {
230
+ const t = tasks.get(e.task);
231
+ if (TASK_STATES.includes(e.state)) t.state = e.state;
232
+ if (typeof e.start === 'string') t.start = e.start;
233
+ if (typeof e.due === 'string') t.due = e.due;
234
+ t.history.push({ at: e.at || null, by: e.by || null, state: e.state || null, start: e.start || null, due: e.due || null, why: e.why || '' });
235
+ } else if (e.kind === 'ask-decline' && typeof e.ask === 'string' && asks.has(e.ask)) {
236
+ const a = asks.get(e.ask);
237
+ if (a.state !== 'open') continue;
238
+ Object.assign(a, { state: 'declined', why: e.why || '', closedAt: e.at || null });
239
+ }
240
+ }
241
+ return { boards, moves, assigns, asks, stages, iniWorkflows, tasks, events: list };
242
+ }
243
+
244
+ /** The stages an initiative runs on a board: its own workflow when one is recorded, else the board's. */
245
+ function workflowOf(board, id, folded) {
246
+ if (board && board.self) return (board.workflow || []);
247
+ const own = folded && folded.iniWorkflows ? folded.iniWorkflows.get(key(board.id, id)) : null;
248
+ return own ? own.stages : ((board && board.workflow) || []);
249
+ }
250
+ /** The template of an initiative's own workflow record, or null when it runs the board's workflow. */
251
+ function templateOf(board, id, folded) {
252
+ const own = folded && folded.iniWorkflows ? folded.iniWorkflows.get(key(board.id, id)) : null;
253
+ return own ? own.template : null;
254
+ }
255
+ const taskOf = (folded, id) => (folded && folded.tasks ? folded.tasks.get(id) || null : null);
256
+ /* ── work items: PMOS tasks and Jira items, one shape ── */
257
+ const ITEM_PROGRESS = Object.freeze({ 'To do': 0, 'In progress': 0.45, 'In review': 0.8, Done: 1 });
258
+ /** A Jira status (and its category) as one of the four item statuses. */
259
+ function itemStatus(status, category) {
260
+ const s = String(status || '');
261
+ if (category === 'done' || /\b(done|closed|resolved|released)\b/i.test(s)) return 'Done';
262
+ if (/review|\bqa\b|test/i.test(s)) return 'In review';
263
+ if (category === 'doing' || /progress|doing|block|started/i.test(s)) return 'In progress';
264
+ return 'To do';
265
+ }
266
+ /** A PMOS task as a work item. */
267
+ function taskItem(t) {
268
+ return { uid: t.id, key: '', kind: t.kind === 'Epic' ? 'Epic' : 'Task', src: 'pmos', title: t.title, status: t.state, blocked: false, owner: t.owner || null,
269
+ ini: t.initiative, epic: t.epic || null, start: t.start || null, end: t.due || null, ref: t.id, board: t.board };
270
+ }
271
+ /** Progress per item, and an epic's status, progress, blocked flag and dates rolled up from its items. Mutates and returns items. */
272
+ function rollupItems(items) {
273
+ for (const w of items) w.progress = ITEM_PROGRESS[w.status] || 0;
274
+ for (const e of items.filter((w) => w.kind === 'Epic')) {
275
+ const kids = items.filter((w) => w.epic && w.epic === e.uid);
276
+ if (!kids.length) continue;
277
+ e.progress = Math.round((kids.reduce((n, w) => n + w.progress, 0) / kids.length) * 100) / 100;
278
+ e.status = kids.every((w) => w.status === 'Done') ? 'Done' : kids.some((w) => w.status !== 'To do') ? 'In progress' : 'To do';
279
+ e.blocked = kids.some((w) => w.blocked);
280
+ const starts = kids.map((w) => w.start).filter(Boolean).sort();
281
+ const ends = kids.map((w) => w.end).filter(Boolean).sort();
282
+ if (!e.start && starts.length) e.start = starts[0];
283
+ if (!e.end && ends.length) e.end = ends[ends.length - 1];
284
+ }
285
+ return items;
286
+ }
287
+
288
+ /** An ISO day moved by whole days ('2026-10-11', 3 → '2026-10-14'). */
289
+ function shiftDay(iso, days) {
290
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || ''));
291
+ if (!m) return null;
292
+ const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]) + Number(days || 0)));
293
+ return d.toISOString().slice(0, 10);
294
+ }
295
+
296
+ /** The nine-stage key a reading names: the current stage, else (accepted) evaluate, else the last done, else the first used. */
297
+ function readingOf(nine, { accepted = false } = {}) {
298
+ const list = Array.isArray(nine) ? nine : [];
299
+ const cur = list.find((s) => s && s.status === 'current');
300
+ if (cur) return cur.key;
301
+ if (accepted) return 'evaluate';
302
+ const done = list.filter((s) => s && s.status === 'done');
303
+ if (done.length) return done[done.length - 1].key;
304
+ const used = list.find((s) => s && s.status !== 'skipped');
305
+ return used ? used.key : 'discover';
306
+ }
307
+
308
+ function deriveStage(board, reading) {
309
+ const wf = (board && board.workflow) || [];
310
+ if (!wf.length) return null;
311
+ const cat = READING_CAT[reading] || 'Discover';
312
+ for (let i = CATEGORIES.indexOf(cat); i >= 0; i--) {
313
+ const s = wf.find((x) => x.cat === CATEGORIES[i]);
314
+ if (s) return s.name;
315
+ }
316
+ return wf[0].name;
317
+ }
318
+
319
+ function stageOf(board, id, reading, folded) {
320
+ if (board && board.self) return { stage: NINE_LABEL[reading] || NINE_LABEL.discover, source: 'derived' };
321
+ const wf = workflowOf(board, id, folded);
322
+ const moved = folded && folded.stages ? folded.stages.get(key(board.id, id)) : null;
323
+ if (moved && wf.some((s) => s.name === moved)) return { stage: moved, source: 'move' };
324
+ return { stage: deriveStage({ workflow: wf }, reading), source: 'derived' };
325
+ }
326
+
327
+ const catOf = (board, stage) => { const s = ((board && board.workflow) || []).find((x) => x.name === stage); return s ? s.cat : null; };
328
+
329
+ /** Validate one initiative's workflow edit. wfNow = the stages it runs now; stage = where it stands. A removed current stage
330
+ * needs moveTo (a stage of the new workflow); a renamed one carries the initiative along. A Build or Deliver stage needs a
331
+ * board with Build and deliver on. */
332
+ function applyIniWorkflow(board, wfNow, stage, template, stages, moveTo) {
333
+ const bad = (error, status = 400) => ({ ok: false, status, error });
334
+ if (board && board.self) return bad('PMOS keeps its derived ladder: its stages come from scripts/workflow-state.js and are not edited here', 409);
335
+ if (template != null && template !== '' && !INI_TEMPLATES[template]) return bad(`template must be one of ${Object.keys(INI_TEMPLATES).join(', ')}`);
336
+ const ed = applyWorkflowEdit({ workflow: wfNow }, stages, null, {});
337
+ if (!ed.ok) return ed;
338
+ if (!boardSettings(board).build && ed.workflow.some(isBuildStage)) return bad(`Build and Deliver stages need a board with Build and deliver on; ${(board && board.name) || 'this board'} has it off`);
339
+ let moved = null;
340
+ if (stage && !ed.workflow.some((s) => s.name === stage) && !ed.renamed[stage]) {
341
+ if (typeof moveTo !== 'string' || !ed.workflow.some((s) => s.name === moveTo)) return bad(`the initiative is in "${stage}", which the new workflow removes: say which stage it moves to (moveTo)`);
342
+ moved = { from: stage, to: moveTo };
343
+ }
344
+ return { ok: true, template: template || null, workflow: ed.workflow, renamed: ed.renamed, moved };
345
+ }
346
+
347
+ /** Order rules: warnings, never a block. facts = { hasSpec }. */
348
+ function moveWarnings(board, row, from, to, facts = {}) {
349
+ const wf = (row && Array.isArray(row.workflow) && row.workflow.length ? row.workflow : (board && board.workflow)) || [];
350
+ const catTo = (wf.find((s) => s.name === to) || {}).cat || null;
351
+ const out = [];
352
+ const a = wf.findIndex((s) => s.name === from);
353
+ const b = wf.findIndex((s) => s.name === to);
354
+ if (a >= 0 && b > a + 1) for (let i = a + 1; i < b; i++) out.push(`Skipped ${wf[i].name}`);
355
+ if (row && row.lane === 'feature' && catTo === 'Do' && !facts.hasSpec) out.push('No spec yet');
356
+ if (row && !row.anchor) out.push('No anchor');
357
+ return out;
358
+ }
359
+ function cardWarnings(board, row, facts = {}) {
360
+ return moveWarnings(board, row, null, row && row.stage, facts);
361
+ }
362
+
363
+ const NAME_MAX = 40;
364
+ /** Validate a workflow edit and say what it moves. rowsByStage: { stageName: [initiative id] } of the board now. */
365
+ function applyWorkflowEdit(board, stages, moveRemoved, rowsByStage) {
366
+ const bad = (error, status = 400) => ({ ok: false, status, error });
367
+ if (board && board.self) return bad('PMOS keeps its derived ladder: its stages come from scripts/workflow-state.js and are not edited here', 409);
368
+ if (!Array.isArray(stages) || !stages.length) return bad('a workflow needs at least one stage');
369
+ const old = ((board && board.workflow) || []).map((s) => s.name);
370
+ const next = [];
371
+ const seen = new Set();
372
+ const renamed = {};
373
+ const wasSeen = new Set();
374
+ for (const [i, s] of stages.entries()) {
375
+ if (!isObj(s)) return bad(`stage ${i + 1} must be { name, cat }`);
376
+ const name = typeof s.name === 'string' ? s.name.trim().replace(/\s+/g, ' ') : '';
377
+ if (!name) return bad(`stage ${i + 1} needs a name`);
378
+ if (name.length > NAME_MAX || /[\u0000-\u001f]/.test(name)) return bad(`stage ${i + 1}: a name is one line of at most ${NAME_MAX} characters`);
379
+ if (seen.has(name.toLowerCase())) return bad(`two stages are named "${name}"; names must be unique`);
380
+ seen.add(name.toLowerCase());
381
+ if (!CATEGORIES.includes(s.cat)) return bad(`stage "${name}": cat must be one of ${CATEGORIES.join(', ')}`);
382
+ if (s.hint != null && (typeof s.hint !== 'string' || s.hint.length > 200)) return bad(`stage "${name}": hint is text of at most 200 characters`);
383
+ if (s.was != null) {
384
+ if (typeof s.was !== 'string' || !old.includes(s.was)) return bad(`stage "${name}": was must name a current stage (${old.join(', ')})`);
385
+ if (wasSeen.has(s.was)) return bad(`two stages say they were "${s.was}"`);
386
+ wasSeen.add(s.was);
387
+ if (s.was !== name) renamed[s.was] = name;
388
+ }
389
+ next.push({ name, cat: s.cat, hint: typeof s.hint === 'string' ? s.hint : '', was: s.was == null ? null : s.was });
390
+ }
391
+ const kept = new Set();
392
+ for (const s of next) kept.add(s.was != null ? s.was : s.name);
393
+ // a stage named like an old one with no `was` keeps it, unless another stage already carries that old one by `was`
394
+ const removed = old.filter((n) => !kept.has(n));
395
+ const targets = new Set(next.map((s) => s.name));
396
+ const mr = isObj(moveRemoved) ? moveRemoved : {};
397
+ const moved = [];
398
+ for (const r of removed) {
399
+ const held = (rowsByStage && rowsByStage[r]) || [];
400
+ if (!held.length) continue;
401
+ const to = mr[r];
402
+ if (typeof to !== 'string' || !to) return bad(`"${r}" holds ${held.length} initiative(s): say where they go (moveRemoved["${r}"])`);
403
+ if (!targets.has(to)) return bad(`moveRemoved["${r}"] must name a stage of the new workflow, not "${to}"`);
404
+ for (const id of held) moved.push({ id, from: r, to });
405
+ }
406
+ return { ok: true, workflow: next.map(({ name, cat, hint }) => ({ name, cat, hint })), renamed, removed, moved };
407
+ }
408
+
409
+ // ── backlog ─────────────────────────────────────────────────────────────────────────────────────────
410
+ const HZ = { now: 0, next: 1, later: 2 };
411
+ const SORTS = ['rank', 'id', 'title', 'stage', 'status', 'owner', 'horizon', 'end', 'start', 'updated', 'progress'];
412
+ const listParam = (v) => (v == null || v === '' ? null : String(v).split(',').map((x) => x.trim()).filter(Boolean));
413
+ const lc = (v) => String(v == null ? '' : v).toLowerCase();
414
+
415
+ function viewFilter(view, me) {
416
+ const mine = new Set([lc(me), lc(me ? `pm:${me}` : '')].filter(Boolean));
417
+ switch (view) {
418
+ case 'now': return (r) => (r.horizon === 'now' || r.horizon === 'next') && r.status !== 'done';
419
+ case 'mine': return (r) => !!r.owner && mine.has(lc(r.owner)) && r.status !== 'done';
420
+ case 'risk': return (r) => r.status === 'at risk' || r.status === 'late';
421
+ case 'noowner': return (r) => !r.owner && r.status !== 'done';
422
+ default: return () => true;
423
+ }
424
+ }
425
+
426
+ function facetOf(rows, field) {
427
+ const m = new Map();
428
+ for (const r of rows) { const k = r[field] == null ? '' : String(r[field]); m.set(k, (m.get(k) || 0) + 1); }
429
+ // { value: count }, most first; a missing value counts under 'none', the word the filters take for it.
430
+ return Object.fromEntries([...m.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([k, n]) => [k === '' ? 'none' : k, n]));
431
+ }
432
+
433
+ /**
434
+ * query = { q, stage, status, owner, horizon, anchor (each a comma list), view, sort ('-' prefix = descending), offset,
435
+ * limit, me, stages (the workflow's stage names, for the stage sort) }. Facets count the rows after the view and the
436
+ * search, before the stage/status/owner/horizon/anchor filters, so each filter shows what choosing it would give.
437
+ */
438
+ function filterRows(rows, query = {}) {
439
+ const view = VIEWS.includes(query.view) ? query.view : 'now';
440
+ const q = lc(query.q).trim();
441
+ let base = (rows || []).filter(viewFilter(view, query.me));
442
+ if (q) base = base.filter((r) => lc(r.title).includes(q) || lc(r.id).includes(q) || lc(r.anchor).includes(q) || lc(r.owner).includes(q));
443
+ const facets = { stage: facetOf(base, 'stage'), status: facetOf(base, 'status'), owner: facetOf(base, 'owner'), horizon: facetOf(base, 'horizon'), anchor: facetOf(base, 'anchor'), cat: facetOf(base, 'cat') };
444
+ let out = base;
445
+ for (const f of ['stage', 'status', 'owner', 'horizon', 'anchor', 'cat']) {
446
+ const want = listParam(query[f]);
447
+ if (!want) continue;
448
+ const set = new Set(want.map(lc));
449
+ out = out.filter((r) => set.has(r[f] == null ? 'none' : lc(r[f])));
450
+ }
451
+ let sort = typeof query.sort === 'string' && query.sort ? query.sort : 'rank';
452
+ const desc = sort.startsWith('-');
453
+ if (desc) sort = sort.slice(1);
454
+ if (!SORTS.includes(sort)) sort = 'rank';
455
+ const stageIdx = new Map((query.stages || []).map((s, i) => [s, i]));
456
+ const statusIdx = new Map(STATUSES.map((s, i) => [s, i]));
457
+ const nul = (v) => (v == null ? '￿' : v);
458
+ const cmpBy = {
459
+ rank: (a, b) => ((HZ[a.horizon] ?? 3) - (HZ[b.horizon] ?? 3)) || ((a.rank ?? 1e9) - (b.rank ?? 1e9)) || a.id.localeCompare(b.id),
460
+ id: (a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }),
461
+ owner: (a, b) => String(nul(a.owner)).localeCompare(String(nul(b.owner))) || a.id.localeCompare(b.id),
462
+ horizon: (a, b) => ((HZ[a.horizon] ?? 3) - (HZ[b.horizon] ?? 3)) || a.id.localeCompare(b.id),
463
+ title: (a, b) => String(a.title).localeCompare(String(b.title)) || a.id.localeCompare(b.id),
464
+ stage: (a, b) => ((stageIdx.get(a.stage) ?? 1e9) - (stageIdx.get(b.stage) ?? 1e9)) || a.id.localeCompare(b.id),
465
+ status: (a, b) => ((statusIdx.get(a.status) ?? 9) - (statusIdx.get(b.status) ?? 9)) || a.id.localeCompare(b.id),
466
+ end: (a, b) => String(nul(a.end)).localeCompare(String(nul(b.end))) || a.id.localeCompare(b.id),
467
+ start: (a, b) => String(nul(a.start)).localeCompare(String(nul(b.start))) || a.id.localeCompare(b.id),
468
+ updated: (a, b) => String(b.updated || '').localeCompare(String(a.updated || '')) || a.id.localeCompare(b.id),
469
+ progress: (a, b) => (pct(a) - pct(b)) || a.id.localeCompare(b.id),
470
+ };
471
+ out = out.slice().sort(cmpBy[sort]);
472
+ if (desc) out.reverse();
473
+ let limit = Number.parseInt(query.limit, 10);
474
+ if (!Number.isFinite(limit) || limit < 1) limit = 25;
475
+ if (!query.all) limit = Math.min(limit, 100); // `all` is for the server's own callers (the kanban columns), never a URL parameter
476
+ let offset = Number.parseInt(query.offset, 10);
477
+ if (!Number.isFinite(offset) || offset < 0) offset = 0;
478
+ return { view, sort: (desc ? '-' : '') + sort, total: out.length, offset, limit, rows: out.slice(offset, offset + limit), facets };
479
+ }
480
+ const pct = (r) => (typeof r.progress === 'number' ? r.progress : -1);
481
+
482
+ /** Board health from its rows: bad when a quarter or more of the open rows are late; warn when any is late or at risk. */
483
+ function health(rows) {
484
+ const open = (rows || []).filter((r) => r.status !== 'done');
485
+ const late = open.filter((r) => r.status === 'late').length;
486
+ const risk = open.filter((r) => r.status === 'at risk').length;
487
+ if (open.length && late / open.length >= 0.25) return 'bad';
488
+ if (late || risk) return 'warn';
489
+ return 'ok';
490
+ }
491
+
492
+ function counts(rows, workflow) {
493
+ const byCat = Object.fromEntries(CATEGORIES.map((c) => [c, 0]));
494
+ const byStage = Object.fromEntries((workflow || []).map((s) => [s.name, 0]));
495
+ const byStatus = Object.fromEntries(STATUSES.map((s) => [s, 0]));
496
+ let open = 0;
497
+ for (const r of rows || []) {
498
+ if (r.cat && r.cat in byCat) byCat[r.cat]++;
499
+ if (r.stage != null) byStage[r.stage] = (byStage[r.stage] || 0) + 1;
500
+ if (r.status in byStatus) byStatus[r.status]++;
501
+ if (r.status !== 'done') open++;
502
+ }
503
+ return { total: (rows || []).length, open, byCat, byStage, byStatus, risk: byStatus['at risk'], late: byStatus.late };
504
+ }
505
+
506
+ /**
507
+ * The Jira sprints of a board from the cached status snapshots (lib/jira-status.js readSnapshot), read-only.
508
+ * snapshots: [{ initiative, fetchedAt, tickets: Map|[] of readings }]; onBoard(initiativeId) → boolean.
509
+ * A ticket with no sprint (the backlog) is in no sprint. source is 'none' when no snapshot of the board was read.
510
+ */
511
+ function sprintsOf(snapshots, onBoard = () => true) {
512
+ const byName = new Map();
513
+ let readAt = null;
514
+ let any = false;
515
+ for (const s of snapshots || []) {
516
+ if (!s || !onBoard(s.initiative)) continue;
517
+ any = true;
518
+ if (s.fetchedAt && (!readAt || s.fetchedAt > readAt)) readAt = s.fetchedAt;
519
+ const tickets = s.tickets instanceof Map ? [...s.tickets.values()] : Array.isArray(s.tickets) ? s.tickets : [];
520
+ for (const t of tickets) {
521
+ if (!t || !t.sprint || !t.sprint.name) continue;
522
+ const name = String(t.sprint.name);
523
+ if (!byName.has(name)) byName.set(name, { name, state: t.sprint.state || null, issues: [], counts: { todo: 0, doing: 0, done: 0 } });
524
+ const sp = byName.get(name);
525
+ const category = ['todo', 'doing', 'done'].includes(t.category) ? t.category : 'todo';
526
+ sp.issues.push({ key: t.key, summary: t.summary || '', status: t.status || '', category, initiative: s.initiative });
527
+ sp.counts[category]++;
528
+ }
529
+ }
530
+ const order = { active: 0, future: 1, closed: 2 };
531
+ const sprints = [...byName.values()].sort((a, b) => ((order[a.state] ?? 3) - (order[b.state] ?? 3)) || a.name.localeCompare(b.name));
532
+ for (const sp of sprints) sp.issues.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }));
533
+ return { source: any ? 'jira' : 'none', readAt, sprints };
534
+ }
535
+
536
+ module.exports = {
537
+ CATEGORIES, WORKFLOW_TEMPLATES, TOOLS, SELF_LADDER, READING_CAT, STATUSES, VIEWS, SORTS,
538
+ templateWorkflow, isTemplateEdited, fold, readingOf, deriveStage, stageOf, catOf, moveWarnings, cardWarnings,
539
+ applyWorkflowEdit, filterRows, health, counts, sprintsOf, key,
540
+ INI_TEMPLATES, TASK_STATES, isBuildStage, templateBuilds, boardSettings, workflowOf, templateOf, applyIniWorkflow, taskOf, shiftDay,
541
+ ITEM_PROGRESS, itemStatus, taskItem, rollupItems,
542
+ };