create-pmos 0.134.0 → 0.135.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/package.json +1 -1
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/bin/notes-replay.js +235 -0
  7. package/workspace/c3-briefing-test.js +130 -0
  8. package/workspace/c3-foundation-test.js +159 -0
  9. package/workspace/c3-ib-items-test.js +105 -0
  10. package/workspace/c3-ib-more-test.js +120 -0
  11. package/workspace/c3-ib-triage-backlog-test.js +119 -0
  12. package/workspace/c3-ini-harness.js +66 -0
  13. package/workspace/c3-initiatives-test.js +140 -0
  14. package/workspace/c3-one-home-test.js +14 -1
  15. package/workspace/c3-reports-test.js +57 -18
  16. package/workspace/c3-settings-test.js +139 -0
  17. package/workspace/c3-work-test.js +42 -10
  18. package/workspace/fixtures/one-home-baseline.json +1 -9
  19. package/workspace/guide/briefing-cards.md +15 -3
  20. package/workspace/guide/briefing.md +12 -8
  21. package/workspace/guide/calendar-popup.md +2 -2
  22. package/workspace/guide/console-calendar.md +39 -10
  23. package/workspace/guide/index.md +1 -0
  24. package/workspace/guide/initiative-boards.md +65 -0
  25. package/workspace/guide/jira.md +3 -1
  26. package/workspace/guide/layout.md +2 -0
  27. package/workspace/guide/my-work.md +5 -3
  28. package/workspace/guide/notes.md +16 -1
  29. package/workspace/guide/reports-dashboards.md +29 -5
  30. package/workspace/guide/settings.md +68 -0
  31. package/workspace/guide/welcome.md +10 -10
  32. package/workspace/lib/calendar-cli.js +3 -2
  33. package/workspace/lib/calendar-layers.js +21 -10
  34. package/workspace/lib/calendar-tidy.js +230 -0
  35. package/workspace/lib/engine/boards.js +542 -0
  36. package/workspace/lib/engine/breakdown-ops.js +180 -0
  37. package/workspace/lib/engine/calendar-import.js +84 -0
  38. package/workspace/lib/engine/calendar-model.js +185 -0
  39. package/workspace/lib/engine/card-state.js +92 -0
  40. package/workspace/lib/engine/derived/derive.js +92 -0
  41. package/workspace/lib/engine/derived/plan.js +3 -0
  42. package/workspace/lib/engine/derived/trust.js +3 -0
  43. package/workspace/lib/engine/doc-health.js +142 -0
  44. package/workspace/lib/engine/doc-sheet.js +75 -0
  45. package/workspace/lib/engine/intake.js +78 -0
  46. package/workspace/lib/engine/outbox.js +86 -0
  47. package/workspace/lib/engine/plan-forecast.js +342 -0
  48. package/workspace/lib/engine/promises.js +29 -0
  49. package/workspace/lib/engine/records/index.js +114 -0
  50. package/workspace/lib/engine/releases.js +96 -0
  51. package/workspace/lib/engine/replies.js +99 -0
  52. package/workspace/lib/engine/reply-contract/reply-contract.js +372 -0
  53. package/workspace/lib/engine/report-families.js +175 -0
  54. package/workspace/lib/engine/roadmap-ops.js +150 -0
  55. package/workspace/lib/engine/sessions-list.js +27 -0
  56. package/workspace/lib/engine/signal-rules.js +95 -0
  57. package/workspace/lib/engine/signals/cards.js +57 -0
  58. package/workspace/lib/engine/signals/detectors.js +370 -0
  59. package/workspace/lib/engine/signals/facts-cli.js +8 -0
  60. package/workspace/lib/engine/signals/facts.js +197 -0
  61. package/workspace/lib/engine/signals/index.js +33 -0
  62. package/workspace/lib/engine/signals/kinds.js +110 -0
  63. package/workspace/lib/engine/signals/layout-paths.js +44 -0
  64. package/workspace/lib/engine/signals/options.js +24 -0
  65. package/workspace/lib/engine/signals/score.js +72 -0
  66. package/workspace/lib/engine/signals/select.js +47 -0
  67. package/workspace/lib/engine/step-planner.js +65 -0
  68. package/workspace/lib/engine/trust/claim-rules.json +93 -0
  69. package/workspace/lib/engine/trust/import-calendar.js +21 -0
  70. package/workspace/lib/engine/trust/import-guard.js +36 -0
  71. package/workspace/lib/engine/trust/ledger.js +53 -0
  72. package/workspace/lib/engine/trust/outbox.js +45 -0
  73. package/workspace/lib/engine/trust/plan.js +5 -0
  74. package/workspace/lib/engine/trust/trust.js +21 -0
  75. package/workspace/lib/engine/truth-table.js +57 -0
  76. package/workspace/lib/engine/workspace-lib.js +24 -0
  77. package/workspace/lib/factory-join.js +1 -1
  78. package/workspace/lib/facts-async.js +4 -4
  79. package/workspace/lib/headless.js +95 -0
  80. package/workspace/lib/import-overlay.js +2 -1
  81. package/workspace/lib/jira-status.js +30 -3
  82. package/workspace/lib/notes-capture.js +9 -1
  83. package/workspace/lib/notes-final.js +38 -13
  84. package/workspace/lib/notes-replay.js +125 -0
  85. package/workspace/lib/notes-screen.js +180 -0
  86. package/workspace/lib/notes-whisper.js +10 -3
  87. package/workspace/lib/outbox-store.js +10 -6
  88. package/workspace/lib/pmos-verbs.js +3 -1
  89. package/workspace/lib/proactive-cli.js +7 -0
  90. package/workspace/lib/proactive.js +15 -2
  91. package/workspace/lib/repo-script.js +36 -0
  92. package/workspace/lib/report-dashboards.js +5 -2
  93. package/workspace/lib/report-frame.js +122 -0
  94. package/workspace/lib/report-schedules.js +113 -0
  95. package/workspace/lib/route-modules.js +9 -2
  96. package/workspace/lib/routes-boards.js +734 -0
  97. package/workspace/lib/routes-brief-page.js +13 -4
  98. package/workspace/lib/routes-briefing.js +22 -11
  99. package/workspace/lib/routes-c3-init.js +4 -3
  100. package/workspace/lib/routes-c3-life.js +2 -1
  101. package/workspace/lib/routes-c3-reports.js +96 -13
  102. package/workspace/lib/routes-c3-strategy.js +4 -3
  103. package/workspace/lib/routes-c3-timeline.js +3 -2
  104. package/workspace/lib/routes-calendar-import.js +3 -2
  105. package/workspace/lib/routes-calendar-views.js +9 -4
  106. package/workspace/lib/routes-doc-health.js +2 -1
  107. package/workspace/lib/routes-doc-sheet.js +2 -1
  108. package/workspace/lib/routes-forecast.js +2 -1
  109. package/workspace/lib/routes-hooks.js +3 -2
  110. package/workspace/lib/routes-initiatives.js +2 -1
  111. package/workspace/lib/routes-intake.js +2 -1
  112. package/workspace/lib/routes-notes.js +55 -9
  113. package/workspace/lib/routes-outbox.js +23 -3
  114. package/workspace/lib/routes-pm-items.js +19 -6
  115. package/workspace/lib/routes-records.js +3 -2
  116. package/workspace/lib/routes-releases.js +2 -1
  117. package/workspace/lib/routes-report-facts.js +4 -3
  118. package/workspace/lib/routes-report-families.js +2 -1
  119. package/workspace/lib/routes-roadmap-ops.js +5 -4
  120. package/workspace/lib/routes-runs.js +6 -5
  121. package/workspace/lib/routes-settings.js +51 -0
  122. package/workspace/lib/routes-signals.js +3 -2
  123. package/workspace/lib/routes-stage-tools.js +220 -0
  124. package/workspace/lib/run-answers.js +2 -1
  125. package/workspace/lib/settings-store.js +226 -0
  126. package/workspace/lib/stage-tools.js +208 -0
  127. package/workspace/lib/vendor/scripts/signals/layout-paths.js +2 -1
  128. package/workspace/lib/vendor/scripts/workspace-lib.js +24 -0
  129. package/workspace/lib/work-evidence.js +73 -0
  130. package/workspace/package-lock.json +2 -2
  131. package/workspace/package.json +1 -1
  132. package/workspace/public/pm/briefing-deck.js +63 -17
  133. package/workspace/public/pm/briefing-model.js +36 -7
  134. package/workspace/public/pm/c3-ambient.js +100 -0
  135. package/workspace/public/pm/c3-brief-needs.js +26 -6
  136. package/workspace/public/pm/c3-brief-pulse.js +12 -4
  137. package/workspace/public/pm/c3-brief-today.js +56 -21
  138. package/workspace/public/pm/c3-brief.js +2 -1
  139. package/workspace/public/pm/c3-calendar.js +161 -42
  140. package/workspace/public/pm/c3-calpop.js +5 -5
  141. package/workspace/public/pm/c3-demo-boards.js +649 -0
  142. package/workspace/public/pm/c3-demo-brief-page.js +1 -1
  143. package/workspace/public/pm/c3-demo-cal4.js +3 -2
  144. package/workspace/public/pm/c3-demo-cal5.js +52 -0
  145. package/workspace/public/pm/c3-demo-data.js +17 -27
  146. package/workspace/public/pm/c3-demo-rep3.js +46 -5
  147. package/workspace/public/pm/c3-demo.js +2 -1
  148. package/workspace/public/pm/c3-dock.js +1 -1
  149. package/workspace/public/pm/c3-ib-docs.js +80 -0
  150. package/workspace/public/pm/c3-ib-initiatives.js +134 -0
  151. package/workspace/public/pm/c3-ib-items.js +201 -0
  152. package/workspace/public/pm/c3-ib-releases.js +51 -0
  153. package/workspace/public/pm/c3-ib-sprints.js +55 -0
  154. package/workspace/public/pm/c3-ib-triage.js +178 -0
  155. package/workspace/public/pm/c3-ii-tabs.js +240 -0
  156. package/workspace/public/pm/c3-ini-records.js +197 -0
  157. package/workspace/public/pm/c3-ini-timeline.js +278 -0
  158. package/workspace/public/pm/c3-ini-ui.js +229 -0
  159. package/workspace/public/pm/c3-ini-work.js +139 -0
  160. package/workspace/public/pm/c3-initiatives.css +218 -0
  161. package/workspace/public/pm/c3-initiatives.js +741 -0
  162. package/workspace/public/pm/c3-plan.js +2 -2
  163. package/workspace/public/pm/c3-reports.css +47 -47
  164. package/workspace/public/pm/c3-reports.js +223 -185
  165. package/workspace/public/pm/c3-settings.css +74 -0
  166. package/workspace/public/pm/c3-settings.js +703 -0
  167. package/workspace/public/pm/c3-shell.js +102 -36
  168. package/workspace/public/pm/c3-terminal-icons.js +2 -2
  169. package/workspace/public/pm/c3-terminal.js +2 -2
  170. package/workspace/public/pm/calendar-views.js +1 -1
  171. package/workspace/public/pm/components.css +319 -213
  172. package/workspace/public/pm/console3.css +99 -23
  173. package/workspace/public/pm/dest-notes.js +26 -4
  174. package/workspace/public/pm/notes-screen-capture.js +165 -0
  175. package/workspace/public/pm/notes-screen-view.js +98 -0
  176. package/workspace/public/pm/panel-trees.js +5 -2
  177. package/workspace/public/pm/pm-work-view.js +233 -146
  178. package/workspace/public/pm/shell-bell.js +3 -2
  179. package/workspace/public/pm/shell-recording.js +29 -8
  180. package/workspace/public/pm/tokens.css +2 -2
  181. package/workspace/public/pm/view-notes.js +46 -9
  182. package/workspace/public/pm/welcome.js +197 -89
  183. package/workspace/public/welcome.html +6 -4
  184. package/workspace/server.js +105 -6
  185. package/workspace/skills/day-check.skill +1 -1
  186. package/workspace/skills/jira-sync.skill +6 -4
  187. package/workspace/skills/morning-brief.skill +1 -1
  188. package/workspace/test-files.json +12 -3
  189. package/workspace/test-lists/c3-ib-items.json +9 -0
  190. package/workspace/test-lists/c3-ib-more.json +9 -0
  191. package/workspace/test-lists/c3-ib-triage-backlog.json +9 -0
  192. package/workspace/test-lists/c3-initiative-boards.json +6 -0
  193. package/workspace/test-lists/calendar-tidy-up.json +6 -0
  194. package/workspace/test-lists/console3-briefing.json +9 -0
  195. package/workspace/test-lists/console3-foundation.json +9 -0
  196. package/workspace/test-lists/console3-reports.json +3 -3
  197. package/workspace/test-lists/meeting-notes.json +10 -4
  198. package/workspace/test-lists/workspace-headless-jobs.json +5 -0
  199. package/workspace/test-lists/workspace-package-script-closure.json +6 -0
  200. package/workspace/lib/vendor/workspace/lib/actor.js +0 -91
  201. package/workspace/lib/vendor/workspace/lib/board-cache.js +0 -156
  202. package/workspace/lib/vendor/workspace/lib/console-state.js +0 -295
  203. package/workspace/lib/vendor/workspace/lib/factory-join.js +0 -158
  204. package/workspace/lib/vendor/workspace/lib/git-info.js +0 -306
  205. package/workspace/lib/vendor/workspace/lib/plan-items.js +0 -922
  206. package/workspace/lib/vendor/workspace/lib/plan-tree.js +0 -419
  207. package/workspace/lib/vendor/workspace/lib/review-gate.js +0 -39
  208. package/workspace/lib/vendor/workspace/lib/sink.js +0 -321
  209. package/workspace/lib/vendor/workspace/lib/state-root.js +0 -587
@@ -1,587 +0,0 @@
1
- 'use strict';
2
- /* lib/state-root.js — WHERE THE PM'S RUNTIME STATE LIVES.
3
- *
4
- * ── The defect this deletes ────────────────────────────────────────────────────────────────────
5
- *
6
- * The workspace used to keep the PM's live runtime state at `workspace/state/`, INSIDE the
7
- * application directory — the same directory the test suite runs from. Sixteen live tmux sessions,
8
- * the workspace registry, eleven authored skills and a 15KB schedules.json sat one `rm -rf` away
9
- * from every suite, every spawned child, and every stray shell command in the tree.
10
- *
11
- * Three rounds of guarding destructive verbs failed. Round 3 patched rmSync/rmdirSync/unlinkSync/
12
- * renameSync; a verifier then destroyed a planted replica nine other ways — writeFileSync,
13
- * truncateSync, openSync(…,'w'), copyFileSync, cpSync, createWriteStream, execSync('rm -rf'),
14
- * `find -delete`, and renameSync's unchecked DESTINATION — with the isolation suite green each
15
- * time. The enumeration of destructive verbs is unbounded, and a shell subprocess defeats any
16
- * in-process fs patch by construction.
17
- *
18
- * So the fix is not another guard. The PM's state MOVES OUT of the application directory. A
19
- * checkout's `workspace/state/` then holds nothing of the PM's; a test may delete all of it and
20
- * lose nothing. Unreachable beats guarded: there is no verb that reaches a file that is not there.
21
- *
22
- * ── What this is, and what it is NOT ───────────────────────────────────────────────────────────
23
- *
24
- * THIS IS LOCAL-ONLY, GITIGNORED RUNTIME STATE: sessions/, workspaces.json, layouts/, scratchpad/,
25
- * schedules.json, deliverables/, the PM's authored skills/, prefs.json, the per-port pid/log files
26
- * and tmux.conf. Machine-written, churny, per-machine, never committed.
27
- *
28
- * THIS IS NOT D64'S FILE-MODE SYSTEM OF RECORD. `pmos/state/*.jsonl` + `planning/` are canonical
29
- * records that live IN the repository that earned them, and `server.js#stateDirFor()` resolves
30
- * those per-workspace, from the workspace's own root. Nothing here touches that path — see the
31
- * SELF_FILE_MODE_STATE constant in server.js, which deliberately still points inside the checkout.
32
- *
33
- * ── Where it goes ──────────────────────────────────────────────────────────────────────────────
34
- *
35
- * PMOS_STATE_DIR an explicit override; wins over everything. Every test run sets it.
36
- * darwin ~/Library/Application Support/pmos
37
- * elsewhere $XDG_STATE_HOME/pmos, else ~/.local/state/pmos
38
- *
39
- * ── The migration ──────────────────────────────────────────────────────────────────────────────
40
- *
41
- * On first run the existing in-repo state is MOVED, not copied and not merged. Losing the PM's
42
- * sixteen sessions would be a worse outcome than the bug this fixes, so the rules are strict:
43
- *
44
- * - it runs ONLY for the platform default location. An explicit PMOS_STATE_DIR — which is what
45
- * a test sets — never migrates anything, because that would be a test moving the PM's live
46
- * state into a scratch directory: the very accident this file exists to make impossible.
47
- * - two populated directories are NEVER merged. If the destination already holds state, the
48
- * legacy directory is left exactly as it is and the caller is told, so a human decides.
49
- * - it is idempotent. After a move the legacy directory is recreated holding only a note, which
50
- * does not count as populated, so a second run is a no-op. Proven by a test that runs it twice.
51
- */
52
- const fs = require('fs');
53
- const os = require('os');
54
- const path = require('path');
55
-
56
- const APP = 'pmos';
57
- const NOTE_NAME = 'MOVED.txt';
58
-
59
- /* ── THE ONLY TWO OUTCOMES THAT MEAN "SETTLED" ─────────────────────────────────────────────────
60
- *
61
- * This set is an ALLOW-list, and that direction is the whole point. `stateHealth` used to open
62
- * `if (m.action !== 'conflict') return { ok: true }` — a DENY-list of one, which quietly declared
63
- * every other outcome healthy. `migrateLegacy` already had a second failure outcome ('failed', the
64
- * filesystem refusing the move), so a run whose move was refused reported `{ok:true}` on every
65
- * surface, fell back nowhere, and showed the PM zero skills and zero schedules with nothing said —
66
- * the same silent no-show as the conflict, with even the stderr line removed.
67
- *
68
- * So the question is inverted: an outcome is healthy only if it is NAMED here. A third failure mode
69
- * added tomorrow is non-ok by default and says something, rather than being silently healthy until
70
- * somebody notices the empty list. Adding an outcome to this set is the deliberate act; forgetting
71
- * to is the safe one. */
72
- const SETTLED_ACTIONS = new Set([
73
- 'none', // nothing to move: no legacy path, an explicit PMOS_STATE_DIR, or a legacy dir holding no state
74
- 'moved', // the contents are now at the per-user root and a note was left behind
75
- ]);
76
-
77
- /* ── AND THE SAME DEFECT ONE FUNCTION UP: AN ERROR CONVERTED INTO A TRUSTED VALUE ──────────────
78
- *
79
- * Inverting the check at `stateHealth` classifies the OUTCOME correctly. It cannot help when the
80
- * outcome itself was manufactured out of a swallowed error, which is what the first version of
81
- * `isPopulated` did:
82
- *
83
- * let names = [];
84
- * try { names = fs.readdirSync(dir); } catch { return false; } // ← the swallow
85
- *
86
- * A directory that could not be READ — a permission, an I/O error, a dangling network mount, a file
87
- * sitting where the directory should be — came back as `false`, indistinguishable from a directory
88
- * that is genuinely empty. `migrateLegacy` then reached a SETTLED outcome ('none'), `stateHealth`
89
- * said `{ok:true}`, and every measured symptom of the original bug returned verbatim: zero skills,
90
- * zero schedules, forty-five schedules that stop firing, and nothing anywhere saying why. An
91
- * allow-list downstream is powerless against it, because by then the error is a legitimate answer.
92
- *
93
- * So the rule this file now holds: AN UNREADABLE DIRECTORY IS NOT AN EMPTY ONE. Reading a directory
94
- * has THREE answers, not two, and "could not tell" travels all the way to the PM with its errno and
95
- * its path — the same treatment 'failed' gets. There is no boolean in this module any more that a
96
- * caller could mistake for an answer; `readState` is the only reader and it is total. */
97
-
98
- /** Why a directory could not be LISTED — a different act from refusing a move, so a different table:
99
- * "cannot write the destination" is the wrong sentence for a directory nobody can open. */
100
- const WHY_UNREADABLE = {
101
- EACCES: 'permission denied — this user cannot list that directory (check its mode and its parents)',
102
- EPERM: 'the operation was not permitted — check ownership, a locked/immutable flag, or macOS privacy protection on that directory',
103
- ENOTDIR: 'a component of the path is a FILE where a directory was expected',
104
- ELOOP: 'a symlink loop in the path',
105
- ENAMETOOLONG: 'the path is too long for this filesystem',
106
- EIO: 'a low-level I/O error — the volume may be failing or a mount may have gone away',
107
- ESTALE: 'a STALE file handle — the network mount this directory is on has been replaced or unmounted',
108
- ENODEV: 'no such device — the volume this directory is on is not attached',
109
- ENXIO: 'no such device or address — the volume this directory is on is not responding',
110
- ETIMEDOUT: 'the filesystem TIMED OUT — a network mount is unreachable',
111
- EHOSTDOWN: 'the host serving this network mount is down',
112
- EHOSTUNREACH: 'the host serving this network mount is unreachable',
113
- ECONNABORTED: 'the connection to this network mount was aborted',
114
- EMFILE: 'this process has too many open files',
115
- ENFILE: 'the system has too many open files',
116
- EBUSY: 'the directory is busy — another process has it locked',
117
- };
118
- /** A sentence for an errno, never an empty string. Same contract as `whyRefused`. */
119
- function whyUnreadable(code) {
120
- if (code && Object.prototype.hasOwnProperty.call(WHY_UNREADABLE, code)) return WHY_UNREADABLE[code];
121
- return code
122
- ? `the filesystem returned ${code} when this directory was listed, which this build has no plain-language translation for`
123
- : 'the filesystem refused to list this directory without an error code';
124
- }
125
-
126
- /* Why the filesystem refused a move, in words the PM can act on. Unlike a conflict — which the PM
127
- * can resolve just by being told the two directories — a refusal cannot be resolved without knowing
128
- * the CAUSE: a permission is fixed differently from a full disk, which is fixed differently from a
129
- * read-only volume. An errno alone ("EACCES") is a code the PM has to go look up, so the code is
130
- * kept AND translated. An unrecognised code still gets a sentence rather than silence. */
131
- const WHY_REFUSED = {
132
- EACCES: 'permission denied — this user cannot write the destination or one of its parent directories',
133
- EPERM: 'the operation was not permitted — check ownership, a locked/immutable flag, or macOS privacy protection on the destination',
134
- EROFS: 'the destination is on a READ-ONLY filesystem',
135
- ENOSPC: 'the disk is FULL — no space left on the destination volume',
136
- EDQUOT: 'this user\'s disk QUOTA on the destination volume is exhausted',
137
- EXDEV: 'the two directories are on DIFFERENT filesystems and the cross-device copy also failed',
138
- ENOENT: 'a path was not there — the destination directory could not be created, or a source entry vanished mid-move',
139
- ENOTDIR: 'a component of one of the paths is a FILE where a directory was expected',
140
- EISDIR: 'a directory was found where a file was expected',
141
- ENOTEMPTY: 'a destination entry already exists and is not empty',
142
- EEXIST: 'a destination entry already exists',
143
- EBUSY: 'a file is IN USE by another process and could not be moved',
144
- ELOOP: 'a symlink loop in one of the paths',
145
- ENAMETOOLONG: 'a path is too long for this filesystem',
146
- EMFILE: 'this process has too many open files',
147
- ENFILE: 'the system has too many open files',
148
- EIO: 'a low-level I/O error on the disk — the volume may be failing or disconnected',
149
- };
150
- /** A sentence for an errno, never an empty string: an unknown code still has to say something. */
151
- function whyRefused(code) {
152
- if (code && Object.prototype.hasOwnProperty.call(WHY_REFUSED, code)) return WHY_REFUSED[code];
153
- return code
154
- ? `the filesystem returned ${code}, which this build has no plain-language translation for`
155
- : 'the filesystem refused the move without an error code';
156
- }
157
-
158
- /**
159
- * Did the migration leave the state root SETTLED? `true` only for an outcome named in
160
- * SETTLED_ACTIONS. A null/absent migration record means no migration was attempted (a caller that
161
- * passed none), which is settled; a record that HAS an action this build does not recognise is not.
162
- */
163
- function isSettled(migration) {
164
- if (migration == null) return true;
165
- const a = migration.action;
166
- return typeof a === 'string' && SETTLED_ACTIONS.has(a);
167
- }
168
-
169
- // Entries that do not make a directory "populated": the checked-in .gitignore that documents the
170
- // layout, the note this migration leaves behind, and Finder's litter. Anything else is the PM's.
171
- const NOT_STATE = new Set(['.gitignore', NOTE_NAME, '.DS_Store']);
172
- // …and the same set is what the migration LEAVES BEHIND. `.gitignore` is a TRACKED file in this
173
- // repository — it documents the directory's layout — so moving it out deletes a tracked file from
174
- // the checkout and shows up as a spurious deletion in `git status`. It is not state; it does not
175
- // travel. (Found the hard way: the first migration in this worktree carried it off.)
176
- const NEVER_MOVE = NOT_STATE;
177
-
178
- /** An explicit override, resolved to an absolute path — or null when unset/blank. */
179
- function overrideDir(env = process.env) {
180
- const v = env && env.PMOS_STATE_DIR;
181
- const s = v == null ? '' : String(v).trim();
182
- return s ? path.resolve(s) : null;
183
- }
184
-
185
- /**
186
- * The per-user runtime state directory for this machine. Pure: every input is injected, so the
187
- * platform branches are assertable without pretending to be another OS.
188
- */
189
- function defaultRoot({ env = process.env, platform = process.platform, home = os.homedir() } = {}) {
190
- const forced = overrideDir(env);
191
- if (forced) return forced;
192
- if (platform === 'darwin') return path.join(home, 'Library', 'Application Support', APP);
193
- const xdg = env && env.XDG_STATE_HOME;
194
- if (xdg && path.isAbsolute(String(xdg))) return path.join(String(xdg), APP);
195
- return path.join(home, '.local', 'state', APP);
196
- }
197
-
198
- /**
199
- * THE ONLY DIRECTORY READ IN THIS MODULE, and it is total: it answers with a fact about what it
200
- * managed to find out, never with a boolean that hides which of two very different things happened.
201
- *
202
- * { known: true, populated: true, names: [...] } it holds something of the PM's
203
- * { known: true, populated: false, names: [...] } it is there and holds no state…
204
- * { known: true, populated: false, absent: true } …or it is not there at all, which is the same
205
- * fact for our purposes and the ordinary case
206
- * { known: false, code, syscall, at, message } WE COULD NOT TELL — and nobody downstream may
207
- * pretend that is the same as "empty"
208
- *
209
- * ENOENT is the one error that IS an answer: a directory that does not exist holds nothing, and that
210
- * is every first run on every machine. Everything else — EACCES, EIO, ENOTDIR, ESTALE, a timed-out
211
- * network mount — is a question this process cannot answer, and it is carried, not swallowed.
212
- *
213
- * There is deliberately NO `isPopulated(dir) -> boolean` any more. It existed, it swallowed, and the
214
- * swallow put back every symptom of the bug this whole module was written to end. If you are about
215
- * to re-add one for convenience, that is the regression.
216
- */
217
- function readState(dir) {
218
- if (!dir) return { known: true, populated: false, absent: true, names: [] };
219
- let names;
220
- try { names = fs.readdirSync(dir); }
221
- catch (e) {
222
- if (e && e.code === 'ENOENT') return { known: true, populated: false, absent: true, names: [] };
223
- return {
224
- known: false, populated: null, names: null,
225
- code: (e && e.code) || null,
226
- syscall: (e && e.syscall) || 'readdir',
227
- at: (e && e.path) || dir,
228
- message: (e && e.message) || String(e),
229
- };
230
- }
231
- return { known: true, populated: names.some((n) => !NOT_STATE.has(n)), names };
232
- }
233
-
234
- /**
235
- * Does a path exist? THREE answers, for the same reason `readState` has three: `fs.existsSync`
236
- * returns `false` both for "not there" and for "this process could not find out", and a caller that
237
- * treats the second as the first is the swallow again, wearing a different function's name.
238
- * 'yes' | 'no' | 'unknown'
239
- */
240
- function presence(p) {
241
- if (!p) return 'no';
242
- try { fs.statSync(p); return 'yes'; }
243
- catch (e) { return e && e.code === 'ENOENT' ? 'no' : 'unknown'; }
244
- }
245
-
246
- function noteText(target, at) {
247
- return [
248
- 'This directory is no longer where the PMOS workspace keeps runtime state.',
249
- '',
250
- `Everything that was here was MOVED on ${at} to:`,
251
- ` ${target}`,
252
- '',
253
- 'It moved because this directory sits inside the application directory the test suite runs',
254
- 'from, which put the PM\'s live sessions, workspace registry and authored skills within reach',
255
- 'of every test. Set PMOS_STATE_DIR to override the location.',
256
- '',
257
- ].join('\n');
258
- }
259
-
260
- /**
261
- * Move an in-repo `workspace/state/` to the per-user location, once.
262
- *
263
- * Returns `{ action, from, to, reason }` and never throws for an expected condition:
264
- * 'none' nothing to move (legacy absent or holding no state)
265
- * 'conflict' BOTH directories hold state — nothing touched, a human decides
266
- * 'moved' the legacy directory's contents are now at `to`
267
- * 'failed' the move was attempted and the filesystem refused; nothing was deleted
268
- * 'unreadable' one of the two directories could not be LISTED, so whether it holds state is
269
- * unknown. Nothing was attempted. This is the outcome that used to be reported as
270
- * 'none' — a settled, healthy, entirely fictitious "there was nothing to move".
271
- */
272
- function migrateLegacy({ legacy, target, now = new Date() } = {}) {
273
- const from = legacy ? path.resolve(legacy) : null;
274
- const to = target ? path.resolve(target) : null;
275
- if (!from || !to || from === to) return { action: 'none', from, to, reason: 'same-or-missing-path' };
276
- const unreadable = (r, which) => ({
277
- action: 'unreadable', from, to,
278
- reason: r.code || 'unreadable',
279
- which, // 'legacy' | 'target' — which of the two we are blind to
280
- code: r.code || null,
281
- syscall: r.syscall || 'readdir',
282
- at: r.at || null,
283
- message: r.message || null,
284
- });
285
- const src = readState(from);
286
- if (!src.known) return unreadable(src, 'legacy');
287
- if (!src.populated) return { action: 'none', from, to, reason: 'legacy-holds-no-state' };
288
- const dst = readState(to);
289
- if (!dst.known) return unreadable(dst, 'target');
290
- if (dst.populated) return { action: 'conflict', from, to, reason: 'both-populated' };
291
-
292
- try {
293
- fs.mkdirSync(path.dirname(to), { recursive: true });
294
- // Entry-by-entry, so an EMPTY destination that already exists (a bare mkdir, a .gitignore) is
295
- // filled rather than refused, and so a cross-device legacy directory still moves.
296
- fs.mkdirSync(to, { recursive: true });
297
- // `src.names` — the listing `readState` already took, and the only one it will ever have taken
298
- // successfully, so a re-read that failed here could not be mistaken for an empty directory.
299
- for (const name of src.names) {
300
- if (NEVER_MOVE.has(name)) continue;
301
- const src = path.join(from, name);
302
- const dst = path.join(to, name);
303
- if (fs.existsSync(dst)) continue; // the .gitignore case; never overwrite
304
- try { fs.renameSync(src, dst); }
305
- catch (e) {
306
- if (e && e.code === 'EXDEV') { fs.cpSync(src, dst, { recursive: true }); fs.rmSync(src, { recursive: true, force: true }); }
307
- else throw e;
308
- }
309
- }
310
- // Leave the legacy directory in place holding only the note: a checkout keeps its (now scratch)
311
- // state/ directory, and the note explains the move to whoever looks there next. The note is in
312
- // NOT_STATE, so this exact directory reads as unpopulated on the next run — the idempotence.
313
- //
314
- // AUDITED SWALLOW, KEPT — but it no longer converts an error into a value anyone trusts. The
315
- // note is advisory: after the loop the legacy directory holds only NOT_STATE entries, so it
316
- // reads as unpopulated on the next run whether or not the note landed, and 'moved' is the
317
- // honest outcome either way. What was wrong was throwing the reason away, so the fact is
318
- // CARRIED on the result instead of vanishing — a human who looks at a bare `state/` with no
319
- // explanation in it can now find out why there is none.
320
- let noteWritten = true, noteError = null;
321
- try { fs.writeFileSync(path.join(from, NOTE_NAME), noteText(to, now.toISOString())); }
322
- catch (e) { noteWritten = false; noteError = (e && e.code) || (e && e.message) || String(e); }
323
- return { action: 'moved', from, to, reason: 'legacy-relocated', noteWritten, noteError };
324
- } catch (e) {
325
- // CARRY THE CAUSE, not just the fact. A refusal the PM cannot diagnose is a refusal they cannot
326
- // fix, and this is the one non-ok outcome where being told the two directories is not enough.
327
- return {
328
- action: 'failed', from, to,
329
- reason: (e && e.code) || 'move-failed',
330
- code: (e && e.code) || null,
331
- syscall: (e && e.syscall) || null,
332
- at: (e && e.path) || null, // the exact path the filesystem refused, when it named one
333
- message: (e && e.message) || String(e),
334
- };
335
- }
336
- }
337
-
338
- /**
339
- * THE call site: resolve the runtime state root, migrating an in-repo legacy directory the first
340
- * time, and create it. `legacy` is `workspace/state/` — passed in rather than derived here so this
341
- * module never has an opinion about where the application happens to be installed.
342
- *
343
- * The migration is skipped whenever the location was chosen explicitly (PMOS_STATE_DIR), because a
344
- * chosen location is a scratch location and moving the PM's state into it is the accident, not the
345
- * fix.
346
- */
347
- function resolveStateRoot({ env = process.env, platform = process.platform, home = os.homedir(), legacy = null } = {}) {
348
- const root = defaultRoot({ env, platform, home });
349
- const chosen = !!overrideDir(env);
350
- const migration = (!chosen && legacy)
351
- ? migrateLegacy({ legacy, target: root })
352
- : { action: 'none', from: legacy, to: root, reason: chosen ? 'explicit-state-dir' : 'no-legacy-path' };
353
- /* THE THIRD SWALLOW IN THIS FILE, and the one with the best-sounding excuse. It used to read
354
- *
355
- * try { fs.mkdirSync(root, { recursive: true }); } catch { } // the caller's first write
356
- * // will report it
357
- *
358
- * — a promise about a caller. The callers are the stores, and what a store does when its
359
- * directory is not there is return an empty list: no skills, no schedules, no registry, and a
360
- * migration record still saying 'none' so `stateHealth` says {ok:true}. That is the identical
361
- * silent no-show, entered through the one door that has nothing to do with the migration at all.
362
- * The error is kept and reported. */
363
- let rootError = null;
364
- try { fs.mkdirSync(root, { recursive: true }); }
365
- catch (e) {
366
- rootError = {
367
- code: (e && e.code) || null,
368
- syscall: (e && e.syscall) || 'mkdir',
369
- at: (e && e.path) || root,
370
- message: (e && e.message) || String(e),
371
- };
372
- }
373
- return { root, migration, chosen, rootError };
374
- }
375
-
376
- /* ── WHAT AN UNSETTLED STATE ROOT MUST NOT MEAN: "YOU HAVE NOTHING" ────────────────────────────
377
- *
378
- * This section was written for the CONFLICT outcome and then, for one round, applied only to it —
379
- * which is how the identical defect survived in the 'failed' outcome with even the stderr line
380
- * removed. Read every "conflict" below as "any outcome not in SETTLED_ACTIONS": both functions now
381
- * key off `isSettled`, so a refused move degrades exactly like a conflict, and so does whatever
382
- * outcome gets added next.
383
- *
384
- * `migrateLegacy` refuses to merge two populated directories, and that refusal is right — only a
385
- * human can decide which sixteen sessions are the real sixteen. What was wrong was what happened
386
- * NEXT. Every store resolved unconditionally against the per-user root with no fallback, so on a
387
- * machine where the per-user root existed and held (say) sessions/ and workspaces.json but NO
388
- * skills/ and NO schedules.json — which is exactly the state an earlier agent round left on the
389
- * PM's machine — the PM opened the workspace and saw ZERO skills and ZERO schedules. Their eleven
390
- * skills and forty-five schedules were on disk the whole time, one directory away.
391
- *
392
- * And a schedule that never loads never fires. That is the same silent no-show this whole
393
- * initiative exists to end, arriving through a different door: no error on any surface, a green
394
- * empty list, and forty-five scheduled runs that simply stop happening. The server did print a
395
- * conflict to stderr — but `pmos` detaches into a log nobody reads, so in practice it printed to
396
- * nobody.
397
- *
398
- * The two functions below are the answer, and the shape of the answer is a product decision:
399
- *
400
- * 1. resolveStore — a READ-AND-WRITE fallback, per store, ONLY where the per-user root has no
401
- * copy at all. If the per-user root has its own `skills/`, there are two copies, the fallback
402
- * does not happen and a human decides: a fallback that let the PM edit one copy while the
403
- * other was authoritative would be a worse trap than the empty list. Where the per-user root
404
- * has NOTHING, there is exactly one copy of that store on the machine, and reading and writing
405
- * the single copy you found cannot fork anything. So the PM's skills load, their schedules
406
- * load AND FIRE, and a fire can still stamp lastRun on the same file it read.
407
- *
408
- * Under a 'failed' move the same argument holds with one extra wrinkle: the move may have got
409
- * part-way. The rule handles it unchanged — a store that DID land in the per-user root is read
410
- * from there, a store that did not is read from where it stayed, and neither is duplicated.
411
- *
412
- * 2. stateHealth — the fallback is never SILENT. It is a discriminated {ok:false} carried on
413
- * every surface that lists these stores (see server.js: /api/skills, /api/schedules,
414
- * /api/workspaces, /api/console) and written into the first line of every new terminal pane.
415
- * It names BOTH directories and says which stores are being read from the old one, so the PM
416
- * is told what to resolve rather than shown a calm list that happens to be right today.
417
- *
418
- * A TEST RUN NEVER REACHES ANY OF THIS. A test names PMOS_STATE_DIR, which makes the location
419
- * `chosen`, which means migrateLegacy is never called and `action` is never 'conflict'. So no
420
- * suite can fall back into the PM's `workspace/state/` — the hermeticity this file bought stands
421
- * exactly as it did.
422
- */
423
-
424
- /**
425
- * Where ONE named store lives, given a migration result.
426
- * Returns `{ path, fellBack }` — `fellBack` true only when this store is being read from the
427
- * legacy root because the per-user root holds no copy of it.
428
- */
429
- function resolveStore({ root, legacy, name, migration } = {}) {
430
- const at = root && name ? path.join(root, name) : null;
431
- /* THE FOURTH SWALLOW. This used to be two `try { fs.existsSync(x) } catch { }` pairs, and
432
- * `existsSync` is itself a swallow with an API: it answers `false` for a path this process could
433
- * not stat, so "I could not look" arrived as "it is not there". Read one way that silently
434
- * DUPLICATES a store (the per-user copy is unreadable, so we fall back and the PM edits the copy
435
- * nobody reads); read the other it silently HIDES one. `presence()` gives the third answer, and
436
- * the rule for it is the conservative one in both directions: do not fall back on a guess. The
437
- * root is already unsettled whenever this code runs, so the PM is already being told something is
438
- * wrong — this only refuses to make it worse behind their back. */
439
- // ANY unsettled outcome falls back, not just 'conflict'. When the filesystem REFUSES the move the
440
- // PM's stores are still sitting in the legacy directory exactly as they were, and resolving them
441
- // to a per-user root that does not hold them is the identical empty-list failure — it just has no
442
- // stderr line to go with it. A partially-completed 'failed' move is handled by the same rule as
443
- // everything else below: a copy in the per-user root wins, so an entry that DID move is read from
444
- // where it landed and an entry that did not is read from where it stayed.
445
- if (!at || !legacy || isSettled(migration)) return { path: at, fellBack: false, unreadable: false };
446
- // A copy in the per-user root wins and no fallback happens: two copies is the ambiguous case, and
447
- // silently preferring either one is how a PM ends up editing the copy nobody reads. An UNKNOWN
448
- // per-user copy is treated as present for exactly that reason — a guess that forks the PM's state
449
- // is the worse of the two mistakes.
450
- const here = presence(at);
451
- if (here === 'yes') return { path: at, fellBack: false, unreadable: false };
452
- if (here === 'unknown') return { path: at, fellBack: false, unreadable: true };
453
- const old = path.join(legacy, name);
454
- const there = presence(old);
455
- if (there === 'no') return { path: at, fellBack: false, unreadable: false };
456
- if (there === 'unknown') return { path: at, fellBack: false, unreadable: true };
457
- return { path: old, fellBack: true, unreadable: false };
458
- }
459
-
460
- /**
461
- * The one fact every surface repeats. `{ok:true}` ONLY for an outcome named in SETTLED_ACTIONS;
462
- * every other outcome is a discriminated failure naming BOTH directories and the stores still being
463
- * read from the old one. `fellBack` is a list of store names (e.g. ['skills', 'schedules.json']).
464
- *
465
- * The `kind` is the discriminant a surface may branch on:
466
- * 'unmigrated' two populated directories — a human picks which is real
467
- * 'failed' the filesystem refused the move; `code`/`why`/`at` say WHY, because unlike a
468
- * conflict this one cannot be resolved from the two directory names alone
469
- * 'unreadable' one of the two directories could not be LISTED, so whether it holds state is
470
- * unknown. The kind that used to be reported as a healthy 'none'.
471
- * 'unusable-root' the state root could not be created at all — nowhere to read, nowhere to write
472
- * 'unknown' an outcome this build does not recognise. It exists so that a future third
473
- * failure mode is LOUD by default. `{ok:true}` is never the fallthrough.
474
- *
475
- * Every kind carries the same keys, so a consumer that only reads `ok`/`error` — which is what
476
- * panel 1, the pane banner and all four API payloads do — needs no change when a kind is added.
477
- */
478
- function stateHealth({ migration, fellBack = [], rootError = null } = {}) {
479
- // FIRST, because it outranks everything else: if the state root itself could not be created there
480
- // is nowhere to read from and nowhere to write to, and every store will render an empty list
481
- // whatever the migration says. This used to be a swallowed mkdir in resolveStateRoot.
482
- if (rootError) {
483
- const code = rootError.code || null;
484
- const why = whyRefused(code);
485
- return {
486
- ok: false, kind: 'unusable-root',
487
- reason: code || 'state-root-not-created',
488
- legacy: (migration && migration.from) || null,
489
- target: (migration && migration.to) || rootError.at || null,
490
- reading_from_legacy: (fellBack || []).filter(Boolean),
491
- code, why, at: rootError.at || null, detail: rootError.message || null,
492
- error: 'the workspace runtime state directory COULD NOT BE CREATED, so nothing can be read or '
493
- + `written and every list will render empty: ${why}`
494
- + (code ? ` (${code}${rootError.syscall ? ' on ' + rootError.syscall : ''})` : '')
495
- + (rootError.at ? ` at ${rootError.at}` : '') + '. '
496
- + 'Fix the cause above, or set PMOS_STATE_DIR to a directory this user can create and write.',
497
- };
498
- }
499
- if (migration == null) return { ok: true }; // no migration was attempted; there is nothing to report
500
- const m = migration;
501
- if (isSettled(m)) return { ok: true };
502
-
503
- const action = typeof m.action === 'string' && m.action ? m.action : '(no action)';
504
- const from = m.from || '(unknown)';
505
- const to = m.to || '(unknown)';
506
- const names = (fellBack || []).filter(Boolean);
507
- // The same sentence in every kind: what is still working, and therefore what is NOT.
508
- const stillRunning = names.length
509
- ? `Until then ${names.join(' and ')} are being read from the OLD directory, so nothing `
510
- + 'silently stops running. '
511
- : 'Anything the per-user directory does not hold is unavailable until this is resolved. ';
512
- const where = `In the checkout: ${from}. Per-user: ${to}. `;
513
- const base = {
514
- ok: false,
515
- reason: m.reason || action,
516
- legacy: m.from || null,
517
- target: m.to || null,
518
- reading_from_legacy: names.slice(),
519
- };
520
-
521
- if (m.action === 'conflict') {
522
- return Object.assign(base, {
523
- kind: 'unmigrated',
524
- error: 'the workspace state is UNMIGRATED — two populated state directories, so nothing was '
525
- + 'moved or merged and a human has to decide which is real. '
526
- + stillRunning + where
527
- + 'Keep one, remove the other, or set PMOS_STATE_DIR to the one you want.',
528
- });
529
- }
530
-
531
- if (m.action === 'failed') {
532
- const code = m.code || (m.reason && m.reason !== 'move-failed' ? m.reason : null);
533
- const why = whyRefused(code);
534
- return Object.assign(base, {
535
- kind: 'failed',
536
- code: code || null,
537
- why,
538
- at: m.at || null,
539
- detail: m.message || null,
540
- error: 'the workspace state COULD NOT BE MOVED out of the checkout — the filesystem refused '
541
- + `the move: ${why}`
542
- + (code ? ` (${code}${m.syscall ? ' on ' + m.syscall : ''})` : '')
543
- + (m.at ? ` at ${m.at}` : '') + '. Nothing was deleted. '
544
- + stillRunning + where
545
- + 'Fix the cause above and restart the workspace, or set PMOS_STATE_DIR to a directory this '
546
- + 'user can write.',
547
- });
548
- }
549
-
550
- if (m.action === 'unreadable') {
551
- const code = m.code || null;
552
- const why = whyUnreadable(code);
553
- const which = m.which === 'target' ? 'the PER-USER state directory' : 'the state directory in the checkout';
554
- return Object.assign(base, {
555
- kind: 'unreadable',
556
- which: m.which || null,
557
- code: code || null,
558
- why,
559
- at: m.at || null,
560
- detail: m.message || null,
561
- // The sentence has to say the thing the old code got WRONG, not just report an errno: this is
562
- // NOT "you have nothing". Nobody knows yet whether there is anything, and that is the point.
563
- error: `${which} COULD NOT BE READ, so whether it holds the workspace's state is UNKNOWN — `
564
- + 'this is NOT the same as it being empty, and nothing was moved, merged or deleted: '
565
- + why
566
- + (code ? ` (${code}${m.syscall ? ' on ' + m.syscall : ''})` : '')
567
- + (m.at ? ` at ${m.at}` : '') + '. '
568
- + stillRunning + where
569
- + 'Make that directory readable by this user and restart the workspace, or set '
570
- + 'PMOS_STATE_DIR to a directory this user can read.',
571
- });
572
- }
573
-
574
- // The default is to SAY SOMETHING. A migration outcome nobody has thought of yet reaches here.
575
- return Object.assign(base, {
576
- kind: 'unknown',
577
- error: 'the workspace state root is NOT SETTLED — the migration reported '
578
- + `\`${action}\`, an outcome this build does not recognise, so it is treated as unresolved. `
579
- + stillRunning + where
580
- + 'Report this, or set PMOS_STATE_DIR to the directory you want used.',
581
- });
582
- }
583
-
584
- module.exports = { APP, NOTE_NAME, NOT_STATE, SETTLED_ACTIONS,
585
- WHY_REFUSED, whyRefused, WHY_UNREADABLE, whyUnreadable, isSettled,
586
- overrideDir, defaultRoot, readState, presence, migrateLegacy, resolveStateRoot, resolveStore,
587
- stateHealth };