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,333 @@
1
+ 'use strict';
2
+ /* today.js — what needs the PM, ranked as Today shows it (pm-os-absorb Run 3, slice 3B, PRD R12, D86 point 5).
3
+ *
4
+ * todayFor(w, { stateDir, sessions }) → { ok, ws, count, cards, banner, next, stall_days, sig }
5
+ * rank(cards) → the cards in Today's order
6
+ * rowKind(row) → the R12 kind (1 to 8) of one needs row, or null for a row that is no card
7
+ * gather(w, { stateDir, sessions }) → { rows, banner } every source's rows, before they are ranked
8
+ * nextDraft(root) → { cadence, slot, label } | null what the empty state names
9
+ * KINDS · KIND_LABEL
10
+ *
11
+ * ONE LIST. Today, GET /api/today and `pmos today` all show this list: the page draws the cards of each source and puts
12
+ * them in this order (public/pm/today-cards.js rankNodes), and lib/today-cli.js prints it. A card has one of the eight
13
+ * kinds of R12 and no other:
14
+ * 1 a gate is owed: an acceptance, a due outcome check, a report to issue 5 an agent breakdown to keep
15
+ * 2 a session is waiting 6 a triaged capture
16
+ * 3 a step was returned to you 7 cycle review
17
+ * 4 work to check: an item in Review, an item with no evidence, a Release 8 needs repair
18
+ * The order is the kind, then the card id. A card is never a status: nothing here changes a record.
19
+ *
20
+ * WHAT IS NOT A CARD. A stalled initiative is a health reason (lib/health.js, `stall_days` from lib/plan-rules.js), so
21
+ * the proactive engine's `stalled` candidates never reach this list. A source that could not be read is a `banner`
22
+ * entry, never an empty list and never a card (R10: a broken source does not read as "nothing needs you").
23
+ *
24
+ * WHERE THE ROWS COME FROM. The needs rows of a repository are made in server.js deriveNeeds, and a route module does
25
+ * not get that function (seams §5.1 ctx). So gather() reads the same sources with the same rules and tests/routes-today
26
+ * compares the two answers row by row. The rows are: lib/plan-items.js needsFor (Keep, no evidence, a line with no
27
+ * effect), every needs provider (lib/needs-providers.js), an open Acceptance Gate, a due outcome check, the queue file
28
+ * the harness writes, an acceptance returned and not accepted since, and the live sessions that wait for the PM.
29
+ */
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+ const crypto = require('crypto');
33
+ const planItems = require('./plan-items.js');
34
+ const needsProviders = require('./needs-providers.js');
35
+ const fmParse = require('./file-mode-parse.js');
36
+ const planRules = require('./plan-rules.js');
37
+ const issues = require('./issues.js');
38
+
39
+ const KINDS = Object.freeze([1, 2, 3, 4, 5, 6, 7, 8]);
40
+ const KIND_LABEL = Object.freeze({
41
+ 1: 'gate', 2: 'session', 3: 'returned', 4: 'check', 5: 'keep', 6: 'capture', 7: 'cycle', 8: 'repair',
42
+ });
43
+ // The row kinds this module owns (the source says `today`): kinds 1 to 3 of R12.
44
+ const OWN = Object.freeze({ accept: 1, outcome: 1, question: 2, session: 2, returned: 3 });
45
+ // Run 1's breakdown rows (lib/plan-items.js needsFor).
46
+ const PLAN = Object.freeze({ keep: 5, evidence: 4, 'plan-flag': 8 });
47
+ const STOPPED = ['closed', 'on-hold', 'cancelled'];
48
+
49
+ const WEEKDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
50
+ const MONTH = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
51
+ const clip = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
52
+
53
+ /** The R12 kind of a needs row (the `today.kind` a provider set, else the kind of the row), or null when it is no card. */
54
+ function rowKind(row) {
55
+ if (!row || typeof row !== 'object') return null;
56
+ if (row.kind === 'provider-error') return null;
57
+ if (row.run_id === 'plan-items:unreadable') return null;
58
+ const t = row.today && typeof row.today === 'object' ? row.today : null;
59
+ if (t && Number.isInteger(t.kind) && t.kind >= 1 && t.kind <= 8) return t.kind;
60
+ if (Object.prototype.hasOwnProperty.call(OWN, row.kind)) return OWN[row.kind];
61
+ if (Object.prototype.hasOwnProperty.call(PLAN, row.kind)) return PLAN[row.kind];
62
+ return 8; // a row that names no kind needs repair: it is shown, not dropped
63
+ }
64
+
65
+ /* ── the cards ──────────────────────────────────────────────────────────────────────────────────────────────────── */
66
+
67
+ const CARD_KEYS = ['id', 'run_id', 'ws', 'kind', 'source', 'row_kind', 'initiative_id', 'cause', 'evidence', 'act'];
68
+
69
+ function make(ws, kind, source, row, p) {
70
+ return {
71
+ id: `${ws}|${row.run_id}`, run_id: row.run_id, ws, kind, source, row_kind: row.kind,
72
+ initiative_id: row.initiative_id || null, cause: clip(p.cause, 200), evidence: clip(p.evidence, 240),
73
+ act: { label: clip(p.act && p.act.label, 24) || 'Open', ...(p.act && p.act.cli ? { cli: clip(p.act.cli, 200) } : {}) },
74
+ ...(p.detail ? { detail: p.detail } : {}),
75
+ };
76
+ }
77
+
78
+ /** One needs row as a card, or null when it is no card. `row.provider` names the source of a provider's row. */
79
+ function cardOf(ws, row) {
80
+ const kind = rowKind(row);
81
+ if (kind === null) return null;
82
+ const t = row.today && typeof row.today === 'object' ? row.today : {};
83
+ const source = typeof row.provider === 'string' ? row.provider : (Object.prototype.hasOwnProperty.call(PLAN, row.kind) ? 'plan-items' : 'today');
84
+ if (typeof row.provider === 'string' || (row.today && typeof row.today === 'object')) {
85
+ return make(ws, kind, t.source || source, row, {
86
+ cause: t.cause || row.question || row.kind, evidence: t.evidence || row.doc || '', act: t.act || { label: 'Open' } });
87
+ }
88
+ if (row.kind === 'accept') {
89
+ const d = row.detail || {};
90
+ return make(ws, 1, 'today', row, {
91
+ cause: d.verdict ? `Acceptance is owed. The evaluator says ${d.verdict}${d.score != null ? ` (${d.score})` : ''}.` : (row.question || 'Acceptance is owed.'),
92
+ evidence: row.doc || `run ${row.run_id}`, act: { label: 'Accept' }, detail: d.verdict || d.score != null ? { verdict: d.verdict || null, score: d.score != null ? d.score : null } : undefined });
93
+ }
94
+ if (row.kind === 'outcome') return make(ws, 1, 'today', row, { cause: row.question, evidence: row.doc || '', act: { label: 'Check' } });
95
+ if (row.kind === 'returned') return make(ws, 3, 'today', row, { cause: row.question, evidence: row.doc || '', act: { label: 'Open' } });
96
+ if (row.kind === 'session') return make(ws, 2, 'today', row, { cause: row.question, evidence: row.doc || '', act: { label: 'Open' } });
97
+ if (row.kind === 'question') return make(ws, 2, 'today', row, { cause: row.question, evidence: row.doc || 'the needs queue', act: { label: 'Open' } });
98
+ if (row.kind === 'keep') {
99
+ const b = row.batch || {};
100
+ return make(ws, 5, 'plan-items', row, {
101
+ cause: `${row.question} proposed by ${b.byLabel || b.by || 'an agent'}`, evidence: row.initiative_id || '', act: { label: 'Keep', cli: null } });
102
+ }
103
+ if (row.kind === 'evidence') return make(ws, 4, 'plan-items', row, { cause: row.question, evidence: row.initiative_id || '', act: { label: 'Open' } });
104
+ if (row.kind === 'plan-flag') return make(ws, 8, 'plan-items', row, { cause: row.question, evidence: row.initiative_id || 'plan-items.jsonl', act: { label: 'Open' } });
105
+ return make(ws, kind, source, row, { cause: row.question || row.kind, evidence: row.doc || '', act: { label: 'Open' } });
106
+ }
107
+
108
+ /** The cards of every row, ranked: the R12 kind first, then the card id (so two lists of the same rows always agree). */
109
+ function rank(cards) {
110
+ return (cards || []).slice().sort((a, b) => a.kind - b.kind || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
111
+ }
112
+
113
+ /* ── gathering the rows ─────────────────────────────────────────────────────────────────────────────────────────── */
114
+
115
+ /**
116
+ * The PMOS checkout this library lives in, as server.js knows it (EMBEDDED_SELF, SELF_ROOT, LEGACY_STATE). Only there is the
117
+ * checkout's own workspace/state/ the file-mode mirror. `SELF` is a plain object so a suite can point it at a scratch tree.
118
+ */
119
+ const SELF = (() => {
120
+ const appDir = path.resolve(__dirname, '..');
121
+ const root = path.resolve(appDir, '..');
122
+ const installed = /[\\/]node_modules[\\/]/.test(__dirname);
123
+ const named = process.env.PMOS_SELF_ROOT ? path.resolve(process.env.PMOS_SELF_ROOT) === root : true;
124
+ const embedded = !installed && named && (fs.existsSync(path.join(root, 'DECISIONS.md')) || fs.existsSync(path.join(root, 'AGENTS.md')));
125
+ return { appDir, root, embedded };
126
+ })();
127
+
128
+ function isDir(d) { try { return fs.statSync(d).isDirectory(); } catch { return false; } }
129
+
130
+ /** The state directory of a tree, as server.js stateDirFor finds it (the route passes the server's own answer). */
131
+ function stateDirOf(root) {
132
+ for (const rel of [['pmos', 'state'], ['state'], ['.pmos']]) {
133
+ const d = path.join(root, ...rel);
134
+ if (isDir(d)) return d;
135
+ }
136
+ // PMOS-self with no pmos/state/: the checkout's own workspace/state/ (server.js returns LEGACY_STATE for the same tree)
137
+ if (SELF.embedded && path.resolve(root) === SELF.root) return path.join(SELF.appDir, 'state');
138
+ return null;
139
+ }
140
+
141
+ /** A tree that carries PMOS records (a PMOS repository), so that a missing state directory is a fault and not an empty list. */
142
+ function carriesPmos(root) {
143
+ return fs.existsSync(path.join(root, 'DECISIONS.md')) || fs.existsSync(path.join(root, 'AGENTS.md'));
144
+ }
145
+
146
+ // A ledger lives in the state directory and in the runs directory of the tree: both are read, as server.js readLedger does.
147
+ function readLedger(w, stateDir, name) {
148
+ const out = [];
149
+ const files = [];
150
+ if (stateDir) files.push(path.join(stateDir, name));
151
+ if (w && w.root) files.push(path.join(w.root, 'planning', 'runs', name));
152
+ for (const f of files) for (const r of fmParse.readJsonlRows(f)) out.push(r);
153
+ return out;
154
+ }
155
+
156
+ // workspaces.json names a tree by this id (server.js wsId); the route asks the server whether a tree is registered by it.
157
+ function wsIdOf(root) { return crypto.createHash('sha1').update(path.resolve(root)).digest('hex').slice(0, 10); }
158
+
159
+ // The linked worktrees of a repository, from its main tree's own `.git/worktrees/<name>/gitdir` files (no git process).
160
+ function linkedTrees(main) {
161
+ const dir = path.join(main, '.git', 'worktrees');
162
+ let names = [];
163
+ try { names = fs.readdirSync(dir).sort(); } catch { return []; }
164
+ const out = [];
165
+ for (const n of names) {
166
+ try { out.push(path.dirname(fs.readFileSync(path.join(dir, n, 'gitdir'), 'utf8').trim())); } catch { /* a stale entry */ }
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /**
172
+ * The tree that speaks for its repository's ledgers: a second tree does not, or every row would show twice. server.js
173
+ * isPlanNeedsTree picks it among the REGISTERED trees: the main tree, else the first registered linked worktree.
174
+ * `registered(root)` says whether the server holds a workspace at that root; a route module cannot list the server's
175
+ * workspaces (seams §5.1 ctx), so the linked trees come from the repository itself and the first by path stands in for
176
+ * the first registered (runs/issues/run3-b.md). Without `registered` the tree is read alone, so it speaks.
177
+ */
178
+ function isLead(root, registered) {
179
+ if (typeof registered !== 'function') return true;
180
+ const me = path.resolve(root);
181
+ let main;
182
+ try { main = planItems.ledgerRoot(me); } catch { return true; }
183
+ if (main === me) return true;
184
+ if (registered(main)) return false;
185
+ const first = linkedTrees(main).map((t) => path.resolve(t)).filter((t) => registered(t)).sort()[0];
186
+ return !first || first === me;
187
+ }
188
+
189
+ function acceptancesOf(w, stateDir) {
190
+ const acc = new Map(); // initiative_id -> { action, at, run_id }
191
+ for (const r of readLedger(w, stateDir, 'acceptances.jsonl')) {
192
+ if (!r || !r.initiative_id) continue;
193
+ const label = String(r.action || '').toLowerCase();
194
+ const action = r.accepted === true ? 'accept' : r.accepted === false ? 'return' : label === 'return' ? 'return'
195
+ : ['accept', 'approve', 'merge', 'publish'].includes(label) ? 'accept' : null;
196
+ const at = r.at || r.date || null;
197
+ const prev = acc.get(r.initiative_id);
198
+ if (prev && prev.at && (!at || at < prev.at)) continue;
199
+ acc.set(r.initiative_id, { action, at, run_id: r.run_id || null, note: r.note || '' });
200
+ }
201
+ return acc;
202
+ }
203
+
204
+ function stoppedOf(w, stateDir) {
205
+ const latest = new Map();
206
+ for (const r of readLedger(w, stateDir, 'lifecycle.jsonl')) {
207
+ if (!r || typeof r.initiative_id !== 'string') continue;
208
+ if (r.state !== 'active' && !STOPPED.includes(r.state)) continue;
209
+ latest.set(r.initiative_id, r.state);
210
+ }
211
+ return new Set([...latest].filter(([, s]) => STOPPED.includes(s)).map(([id]) => id));
212
+ }
213
+
214
+ function queueRows(w, stateDir, banner) {
215
+ let f = path.join(w.root, '.pmos', 'needs.json');
216
+ if (!fs.existsSync(f)) f = stateDir ? path.join(stateDir, 'needs.json') : null;
217
+ if (!f || !fs.existsSync(f)) return [];
218
+ let parsed;
219
+ try { parsed = JSON.parse(fs.readFileSync(f, 'utf8')); }
220
+ catch { banner.push({ source: 'needs queue', error: 'the needs-you queue file is unreadable or not valid JSON' }); return []; }
221
+ if (!Array.isArray(parsed)) { banner.push({ source: 'needs queue', error: 'the needs-you queue file is not a JSON array' }); return []; }
222
+ return parsed.filter((n) => n && typeof n === 'object').map((n) => ({
223
+ run_id: n.run_id || null, kind: n.kind || 'question', initiative_id: n.initiative_id || null, derived: !!n.derived,
224
+ question: n.question || n.t || '(unstated)', doc: n.doc || null, ...(n.today && typeof n.today === 'object' ? { today: n.today } : {}),
225
+ })).filter((n) => n.run_id);
226
+ }
227
+
228
+ /** A 4D `item-no-evidence` row for an item Run 1's `evidence` row already names (R12 kind 4, one fact, one card). */
229
+ function isDuplicateNoEvidence(row, evidenceIds) {
230
+ if (!row || row.kind !== 'item-no-evidence') return false;
231
+ const m = /^work-check-needs:no-evidence:(.+)$/.exec(String(row.run_id || ''));
232
+ return !!m && evidenceIds.has(m[1]);
233
+ }
234
+
235
+ /**
236
+ * Every source's rows for workspace `w`, and the sources that could not be read.
237
+ * w { id, root }, a registered workspace
238
+ * stateDir the workspace's state directory (the route passes ctx.stateDirFor(w)); null reads none
239
+ * sessions the live sessions (ctx.sessionList()); a session whose `shown` is "needs" is a waiting session
240
+ * registered (root) → boolean whether the server holds a workspace at that root; decides which tree of a repository leads
241
+ */
242
+ function gather(w, { stateDir = stateDirOf(w.root), sessions = [], registered } = {}) {
243
+ const banner = [];
244
+ const rows = [];
245
+ const lead = isLead(w.root, registered);
246
+ if (!stateDir && carriesPmos(w.root)) banner.push({ source: 'state directory', error: 'this repository has PMOS records but no state directory was found, so acceptances and checks were not read' });
247
+ if (lead) {
248
+ try { for (const r of planItems.needsFor(w.root)) rows.push({ ...r, ws: w.id }); }
249
+ catch (e) { banner.push({ source: 'plan-items', error: `the plan-items ledger could not be read: ${clip((e && e.message) || 'error', 160)}` }); }
250
+ }
251
+ // Run 1's `evidence:<item>` row and 4D's `work-check-needs:no-evidence:<item>` row say the same thing about the same item:
252
+ // the item is counted once, by Run 1's row (provider-cards.js drops the same rows on the page)
253
+ const evidenceOf = new Set(rows.filter((r) => r.kind === 'evidence').map((r) => String(r.run_id).replace(/^evidence:/, '')));
254
+ for (const r of needsProviders.rowsFor(w, { lead })) {
255
+ if (r.kind === 'provider-error') banner.push({ source: r.provider || 'a needs source', error: clip(r.question, 240) });
256
+ else if (isDuplicateNoEvidence(r, evidenceOf)) continue;
257
+ else rows.push(r);
258
+ }
259
+ for (const b of needsProviders.bannersFor(w, { lead })) banner.push({ source: b.source, error: clip(b.error, 240) });
260
+ const stopped = stoppedOf(w, stateDir);
261
+ const queue = queueRows(w, stateDir, banner);
262
+ const seen = new Set(queue.map((n) => n.run_id));
263
+ for (const n of queue) rows.push(n);
264
+ if (stateDir) {
265
+ const settled = new Set();
266
+ for (const a of readLedger(w, stateDir, 'acceptances.jsonl')) if (a && a.run_id) settled.add(a.run_id);
267
+ for (const e of readLedger(w, stateDir, 'evals.jsonl')) {
268
+ if (!e || !e.run_id || settled.has(e.run_id) || seen.has(e.run_id)) continue;
269
+ rows.push({ run_id: e.run_id, initiative_id: e.initiative_id || null, kind: 'accept', derived: true,
270
+ question: 'Acceptance Gate open', doc: e.rubric_path || e.rubric || null,
271
+ detail: { verdict: e.verdict ? String(e.verdict) : null, score: e.score != null ? e.score : null } });
272
+ }
273
+ const disposed = new Set();
274
+ for (const c of readLedger(w, stateDir, 'outcome_checks.jsonl')) if (c && c.initiative_id) disposed.add(c.initiative_id);
275
+ const today = new Date().toISOString().slice(0, 10);
276
+ let files = [];
277
+ try { files = fs.readdirSync(path.join(stateDir, 'initiatives')).filter((f) => f.endsWith('.md')).sort(); } catch { /* none */ }
278
+ for (const f of files) {
279
+ const fm = fmParse.leadingFrontmatter(path.join(stateDir, 'initiatives', f));
280
+ if (!fm.id || !fm.outcome_metric || disposed.has(fm.id) || !fmParse.outcomeDue(fm, today)) continue;
281
+ const run_id = `outcome:${fm.id}`;
282
+ if (seen.has(run_id)) continue;
283
+ rows.push({ run_id, initiative_id: fm.id, kind: 'outcome', derived: true,
284
+ question: `An outcome check is due: "${fm.outcome_metric}" (horizon ${fm.outcome_horizon || 'none declared'})`, doc: null });
285
+ }
286
+ // an initiative stopped by a lifecycle line is not waiting for a returned step, as the proactive engine also holds
287
+ for (const [id, a] of acceptancesOf(w, stateDir)) {
288
+ if (a.action !== 'return' || stopped.has(id)) continue;
289
+ rows.push({ run_id: `returned:${id}`, initiative_id: id, kind: 'returned', derived: true,
290
+ question: 'You returned this at the Acceptance Gate and it has not been accepted since.', doc: a.note || null });
291
+ }
292
+ }
293
+ for (const s of sessions || []) {
294
+ if (!s || s.ws !== w.id || s.shown !== 'needs') continue;
295
+ rows.push({ run_id: `session:${s.id}`, initiative_id: s.initiative || planItems.initiativeOf(s.plan_item) || null, kind: 'session', derived: true,
296
+ question: 'A session is waiting for you.', doc: [s.name, s.label].filter(Boolean).join(' · ') || null });
297
+ }
298
+ return { rows, banner };
299
+ }
300
+
301
+ /* ── the empty state ────────────────────────────────────────────────────────────────────────────────────────────── */
302
+
303
+ /** The next report draft: the last issue of the biweekly plus `report_cycle_days`, or null when nothing was issued yet. */
304
+ function nextDraft(root) {
305
+ let last = null;
306
+ try { last = issues.lastIssue(issues.readIssues(root), 'biweekly'); } catch { return null; }
307
+ if (!last || !/^\d{4}-\d{2}-\d{2}$/.test(String(last.slot))) return null;
308
+ const days = planRules.readRules(root).report_cycle_days;
309
+ const [y, m, d] = last.slot.split('-').map(Number);
310
+ const due = new Date(Date.UTC(y, m - 1, d + days));
311
+ const slot = due.toISOString().slice(0, 10);
312
+ return { cadence: 'biweekly', slot, label: `biweekly draft ${WEEKDAY[due.getUTCDay()]} ${due.getUTCDate()} ${MONTH[due.getUTCMonth()]}` };
313
+ }
314
+
315
+ /* ── the answer ─────────────────────────────────────────────────────────────────────────────────────────────────── */
316
+
317
+ /** The ranked list for one workspace: the answer of GET /api/today?ws=, and what `pmos today` prints. */
318
+ function todayFor(w, { stateDir, sessions, registered } = {}) {
319
+ if (!w || !w.root) return { ok: false, error: 'no workspace resolved for Today' };
320
+ let g;
321
+ try { g = gather(w, { stateDir: stateDir === undefined ? stateDirOf(w.root) : stateDir, sessions, registered }); }
322
+ catch (e) { return { ok: false, error: `Today could not be read: ${clip((e && e.message) || 'error', 160)}` }; }
323
+ const cards = rank(g.rows.map((r) => cardOf(w.id, r)).filter(Boolean));
324
+ const next = cards.length ? null : nextDraft(w.root);
325
+ // what the page shows: each card's id, kind, words and act, and the notice. A change to any of them moves the sig.
326
+ const sig = crypto.createHash('sha1').update(JSON.stringify([cards.map((c) => [c.id, c.kind, c.cause, c.evidence, c.act.label]), g.banner, next && next.label])).digest('hex').slice(0, 16);
327
+ return {
328
+ ok: true, ws: w.id, count: cards.length, cards, banner: g.banner,
329
+ next, stall_days: planRules.readRules(w.root).stall_days, sig,
330
+ };
331
+ }
332
+
333
+ module.exports = { isDuplicateNoEvidence, KINDS, KIND_LABEL, CARD_KEYS, SELF, rowKind, cardOf, rank, gather, stateDirOf, wsIdOf, isLead, nextDraft, todayFor };
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+ /* triage.js — what a captured idea would become as an initiative: "Proposed: <title> · <lane> · <anchor>", and
3
+ * whether it matches an earlier rejection (pm-os-absorb Run 2, PRD R16, R12 kind 6; map-spec X16).
4
+ *
5
+ * captures(root) → [{ id, path, title, timestamp, by, text }] every capture note, oldest first
6
+ * triage(root, { since } = {}) → { ok, proposals: [proposal], flags: [{ kind, path, why }] }
7
+ * proposal = { capture, path, title, lane, laneLabel, anchor, anchorTitle, rejection: { path, title } | null, by }
8
+ *
9
+ * WHERE A CAPTURE IS. ⌘K (Run 3, slice 3C) writes each capture as a note `<state>/requirements/capture-YYYY-MM-DD-<n>.md`
10
+ * (`<state>` is `planning/` for PMOS-self, `pmos/state/` in a kit repository: the folder that holds the initiatives),
11
+ * frontmatter `type: Reference`, `title`, `timestamp`, `source: capture`, `by`; the body is the captured text. No new
12
+ * ledger (X16).
13
+ *
14
+ * WHICH CAPTURES ARE PROPOSED. A note is triaged until an initiative is created from it: the initiative writer's
15
+ * `created` line in placements.jsonl names it in `from` (lib/placements.js createdFrom). A Dismiss is the needs queue's
16
+ * own dismissal of the row (its run_id), so triage keeps no state of its own. `since` (an ISO time) narrows the run to
17
+ * the notes newer than a caller's last run, compared with the note's `timestamp`.
18
+ *
19
+ * WHAT IS PROPOSED, and how — deterministic, no model call. The title is the first sentence of the text (at most 60
20
+ * characters). The lane is the profile's first work lane that builds something new (`feature`, or `new-feature` in a
21
+ * document repository). The anchor is the KR or health budget whose name shares the most words with the text (at least
22
+ * two), or none: the Add then asks for one, because the writer refuses an initiative with no anchor. This is a first
23
+ * guess for the PM to correct, not a judgement; the pmos-router skill is the judgement.
24
+ *
25
+ * THE EARLIER-REJECTION CHECK (the router's prior-rejection step, R12 kind 6). Each file under `planning/out-of-scope/`
26
+ * (except README.md) is read for its title. A capture matches when it holds at least two of the title's words and at
27
+ * least half of them (words of 3 or more letters that are not common words). A match names the file; it does not
28
+ * stop the Add: the concept returns only if the PM overrides the recorded reasoning.
29
+ */
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+ const consoleState = require('./console-state');
33
+ const placements = require('./placements.js');
34
+ const rowsLib = require('./initiative-rows.js');
35
+
36
+ const NOTE_RE = /^capture-(\d{4}-\d{2}-\d{2})-([1-9]\d{0,3})\.md$/;
37
+ const STOP = new Set(['the', 'and', 'for', 'with', 'from', 'that', 'this', 'want', 'wants', 'before', 'after', 'into', 'each',
38
+ 'what', 'when', 'have', 'has', 'are', 'was', 'were', 'not', 'but', 'our', 'your', 'their', 'they', 'them', 'can', 'should',
39
+ 'would', 'could', 'will', 'all', 'any', 'out', 'one', 'two', 'its', 'than', 'then', 'there', 'here', 'also', 'via', 'per']);
40
+
41
+ function engine() {
42
+ const loaded = consoleState.loadEngine();
43
+ if (!loaded.ok) throw new Error(loaded.error);
44
+ return loaded.engine;
45
+ }
46
+ const words = (s) => [...new Set(String(s || '').toLowerCase().replace(/[`*_#>[\]()]/g, ' ').split(/[^a-z0-9]+/).filter((w) => w.length >= 3 && !STOP.has(w)))];
47
+
48
+ /** The folder that holds the initiatives' folder: `planning/` or `pmos/state/`. */
49
+ function stateDir(root) { return path.dirname(engine().layout(root).initiatives); }
50
+
51
+ function readNote(file) {
52
+ const text = fs.readFileSync(file, 'utf8');
53
+ const fm = engine().parseLeadingFrontmatter(text) || {};
54
+ const m = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(text);
55
+ return { fm, body: (m ? text.slice(m[0].length) : text).trim() };
56
+ }
57
+
58
+ function captures(root) {
59
+ const dir = path.join(stateDir(root), 'requirements');
60
+ let names = [];
61
+ try { names = fs.readdirSync(dir).filter((n) => NOTE_RE.test(n)); } catch { return []; }
62
+ const out = [];
63
+ for (const n of names) {
64
+ const file = path.join(dir, n);
65
+ let note;
66
+ try { if (!fs.lstatSync(file).isFile()) continue; note = readNote(file); } catch { continue; }
67
+ if (String(note.fm.source || '').trim() !== 'capture') continue;
68
+ out.push({ id: n.replace(/\.md$/, ''), path: path.relative(root, file).split(path.sep).join('/'), title: String(note.fm.title || '').trim(),
69
+ timestamp: String(note.fm.timestamp || '').trim(), by: String(note.fm.by || '').trim() || null, text: note.body });
70
+ }
71
+ const key = (c) => { const m = NOTE_RE.exec(c.id + '.md'); return `${m[1]}-${m[2].padStart(4, '0')}`; };
72
+ return out.sort((a, b) => (key(a) < key(b) ? -1 : 1));
73
+ }
74
+
75
+ function rejections(root) {
76
+ const dir = path.join(root, 'planning', 'out-of-scope');
77
+ let names = [];
78
+ try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.md') && n !== 'README.md' && n !== 'index.md').sort(); } catch { return []; }
79
+ const out = [];
80
+ for (const n of names) {
81
+ try {
82
+ const { fm, body } = readNote(path.join(dir, n));
83
+ const heading = /^#\s+(.+)$/m.exec(body);
84
+ const title = String(fm.title || (heading && heading[1]) || n.replace(/\.md$/, '').replace(/-/g, ' ')).trim();
85
+ out.push({ path: `planning/out-of-scope/${n}`, title, words: words(title) });
86
+ } catch { /* an unreadable file is skipped */ }
87
+ }
88
+ return out;
89
+ }
90
+
91
+ function firstSentence(text) {
92
+ const one = String(text || '').replace(/\s+/g, ' ').trim();
93
+ const m = /^(.+?[.!?])(\s|$)/.exec(one);
94
+ let s = (m ? m[1] : one).replace(/[.!?]+$/, '').trim();
95
+ if (s.length > 60) s = s.slice(0, 60).replace(/\s+\S*$/, '').trim();
96
+ return s ? s[0].toUpperCase() + s.slice(1) : '';
97
+ }
98
+
99
+ /** The anchor whose name shares the most words with the text (two at least), from the OKR files and the budgets. */
100
+ function guessAnchor(root, text) {
101
+ const want = new Set(words(text));
102
+ const cands = [];
103
+ try {
104
+ const loaded = consoleState.loadEngine();
105
+ const A = require(path.join(path.dirname(loaded.from), 'anchor-resolve.js'));
106
+ const index = A.parseOkrIndex(root);
107
+ if (index.ok) for (const o of index.objectives) for (const k of o.krs) cands.push({ token: k.token, title: k.name || k.token, words: words(k.name) });
108
+ const L = loaded.engine.layout(root);
109
+ const budgets = fs.readFileSync(L.budgets, 'utf8');
110
+ for (const line of budgets.split('\n')) {
111
+ const m = /^\|\s*`?(health:[a-z0-9-]+)`?\s*\|\s*([^|]*)\|/.exec(line);
112
+ if (m) cands.push({ token: m[1], title: m[1], words: words(m[1].slice(7) + ' ' + m[2]) });
113
+ }
114
+ } catch { /* no resolver or no budgets: no guess */ }
115
+ let best = null;
116
+ for (const c of cands) {
117
+ const score = c.words.filter((w) => want.has(w)).length;
118
+ if (score >= 2 && (!best || score > best.score)) best = { ...c, score };
119
+ }
120
+ return best;
121
+ }
122
+
123
+ function triage(root, { since } = {}) {
124
+ const flags = [];
125
+ let all;
126
+ try { all = captures(root); } catch (e) { return { ok: false, proposals: [], flags: [{ kind: 'unreadable', path: null, why: String((e && e.message) || e).slice(0, 160) }] }; }
127
+ let done = {};
128
+ try { done = placements.createdFrom(root); } catch { done = {}; }
129
+ const sinceMs = since ? Date.parse(since) : NaN;
130
+ let groups = {};
131
+ try { groups = rowsLib.workGroupOf(root); } catch { groups = {}; }
132
+ const lane = ['feature', 'new-feature'].find((l) => groups[l] === 'work') || Object.keys(groups).find((l) => groups[l] === 'work') || 'feature';
133
+ const rej = rejections(root);
134
+ const proposals = [];
135
+ for (const c of all) {
136
+ if (done[c.id]) continue;
137
+ if (Number.isFinite(sinceMs) && !(Date.parse(c.timestamp) > sinceMs)) continue;
138
+ if (!c.text) { flags.push({ kind: 'empty', path: c.path, why: `${c.path} holds no text` }); continue; }
139
+ const title = firstSentence(c.text) || c.title;
140
+ const anchor = guessAnchor(root, c.text);
141
+ const have = new Set(words(c.text));
142
+ const hit = rej.find((r) => { const shared = r.words.filter((w) => have.has(w)).length; return shared >= 2 && shared * 2 >= r.words.length; });
143
+ proposals.push({ capture: c.id, path: c.path, title, lane, laneLabel: lane.replace(/-/g, ' '), anchor: anchor ? anchor.token : null,
144
+ anchorTitle: anchor ? anchor.title : null, rejection: hit ? { path: hit.path, title: hit.title } : null, by: c.by });
145
+ }
146
+ return { ok: true, proposals, flags };
147
+ }
148
+
149
+ /** The proposal of one capture, whatever its state (`pmos initiative new --from`). `done: true` only when an
150
+ * initiative was created from it; `empty: true` when the note holds no text (triage flags it and proposes nothing). */
151
+ function proposalFor(root, captureId) {
152
+ const p = triage(root).proposals.find((x) => x.capture === captureId);
153
+ if (p) return p;
154
+ const c = captures(root).find((x) => x.id === captureId);
155
+ if (!c) return null;
156
+ let made = {};
157
+ try { made = placements.createdFrom(root); } catch { made = {}; }
158
+ const base = { capture: c.id, path: c.path, title: firstSentence(c.text) || c.title, lane: null, anchor: null, rejection: null };
159
+ if (made[c.id]) return { ...base, done: true, initiative: made[c.id] };
160
+ return { ...base, empty: !c.text };
161
+ }
162
+
163
+ module.exports = { captures, triage, proposalFor, rejections, words, firstSentence, stateDir, NOTE_RE };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+ /* verify-work-cli.js — `pmos verify-work`: mark work items integrated from the default branch (pm-os-absorb Run 4,
3
+ * PRD R7 and R24, map-spec X26, D70). Guide page: guide/integrated-work.md.
4
+ *
5
+ * pmos verify-work [--branch <ref>] [--dry-run] [--json] [--root <dir>]
6
+ *
7
+ * The verb runs the repository's OWN scripts/verify-work.js in a child process, in that repository. The factory
8
+ * ledger (planning/work-items.jsonl) has one writer, scripts/work-item.js, and that writer appends only to the ledger
9
+ * of the repository it sits in. So the copy that runs must be the repository's: a copy shipped with the workspace
10
+ * would write nowhere. A repository with no factory writer is told so, and nothing runs.
11
+ *
12
+ * Local refs only: the script runs `git rev-parse`, `git log` and `git merge-base`, never `git fetch` (D70). A
13
+ * `--branch` ref must be on the default branch, or the script refuses it. Pull first (Sync) to see newer commits.
14
+ * Not a PM-only act: `integrated` is a factory event, and the writer decides what may be appended.
15
+ * Exit codes: 0 done, 1 refused (or no writer), 2 usage.
16
+ */
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { execFileSync, spawnSync } = require('child_process');
20
+
21
+ const COMMANDS = { 'verify-work': [] };
22
+ const USAGE = [
23
+ 'usage: pmos verify-work [--branch <ref>] [--dry-run] [--json] [--root <dir>]',
24
+ 'Marks a work item integrated when a commit on the default branch has the trailer "Work-Item: <id>".',
25
+ 'It reads local refs only and never fetches. Pull first to see newer commits.',
26
+ 'Run it again at any time: an item that is already integrated is not written twice.',
27
+ ];
28
+ const BOOL = new Set(['json', 'dry-run', 'help']);
29
+ const VALUE = new Set(['branch', 'root']);
30
+
31
+ function repoRoot(cwd) {
32
+ try { return execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || cwd; }
33
+ catch { return cwd; }
34
+ }
35
+
36
+ function parse(args) {
37
+ const flags = {}; const pos = [];
38
+ for (let i = 0; i < args.length; i++) {
39
+ const a = args[i];
40
+ if (a === '-h') { flags.help = true; continue; }
41
+ if (!a.startsWith('--')) { pos.push(a); continue; }
42
+ const k = a.slice(2);
43
+ if (BOOL.has(k)) { flags[k] = true; continue; }
44
+ if (VALUE.has(k)) {
45
+ const v = args[i + 1];
46
+ if (v === undefined || v.startsWith('--')) return { error: `--${k} needs a value` };
47
+ flags[k] = v; i++; continue;
48
+ }
49
+ return { error: `unknown option --${k}` };
50
+ }
51
+ return { flags, pos };
52
+ }
53
+
54
+ async function run(argv, { say = (s) => console.log(s), cwd = process.cwd() } = {}) {
55
+ const args = argv[0] === 'verify-work' ? argv.slice(1) : argv.slice();
56
+ if (args[0] === 'help') { for (const l of USAGE) say(l); return 0; }
57
+ const p = parse(args);
58
+ if (p.error) { say(`✗ ${p.error}`); for (const l of USAGE) say(l); return 2; }
59
+ if (p.flags.help) { for (const l of USAGE) say(l); return 0; }
60
+ if (p.pos.length) { say(`✗ pmos verify-work takes no argument "${p.pos[0]}"`); for (const l of USAGE) say(l); return 2; }
61
+
62
+ // The main working tree: its ledger is the one the views read, also when this runs in a linked worktree.
63
+ const root = require('./plan-tree.js').mainTree(p.flags.root ? path.resolve(cwd, p.flags.root) : repoRoot(cwd));
64
+ const script = path.join(root, 'scripts', 'verify-work.js');
65
+ if (!fs.existsSync(script)) {
66
+ const why = `${root} has no factory writer (scripts/verify-work.js), so there is no work item to mark`;
67
+ say(p.flags.json ? JSON.stringify({ ok: false, error: why }) : `✗ ${why}.`);
68
+ return 1;
69
+ }
70
+ const pass = [];
71
+ if (p.flags.branch) pass.push('--branch', p.flags.branch);
72
+ if (p.flags['dry-run']) pass.push('--dry-run');
73
+ if (p.flags.json) pass.push('--json');
74
+ const r = spawnSync(process.execPath, [script, ...pass], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
75
+ for (const line of String(r.stdout || '').split('\n')) if (line) say(line);
76
+ if (r.status === null) { say(`✗ scripts/verify-work.js stopped: ${(r.error && r.error.message) || r.signal}`); return 1; }
77
+ if (r.status !== 0 && r.stderr && r.stderr.trim()) say(r.stderr.trim().split('\n').slice(-3).join('\n'));
78
+ return r.status === 0 ? 0 : r.status === 2 ? 2 : 1;
79
+ }
80
+
81
+ module.exports = { run, COMMANDS, USAGE };