@invisibleloop/pulse 0.20.11 → 0.20.20

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 (266) hide show
  1. package/package.json +6 -5
  2. package/public/.pulse-ui-version +1 -1
  3. package/scripts/build.js +26 -0
  4. package/scripts/release-version.js +17 -8
  5. package/scripts/strip-server.test.js +60 -0
  6. package/src/agent/checklist.md +8 -12
  7. package/src/agent/commands/pulse-start.md +2 -0
  8. package/src/agent/commands/verify.md +30 -8
  9. package/src/agent/guide-components.md +7 -25
  10. package/src/agent/guide-routing.md +0 -12
  11. package/src/agent/guide-server.md +102 -1
  12. package/src/agent/guide-spec.md +8 -1
  13. package/src/agent/guide-styles.md +23 -0
  14. package/src/agent/identity.md +9 -0
  15. package/src/agent/workflow.md +4 -23
  16. package/src/cli/check-bundles.js +113 -0
  17. package/src/cli/check-bundles.test.js +137 -0
  18. package/src/cli/cli.test.js +105 -0
  19. package/src/cli/dev.js +48 -3
  20. package/src/cli/diagnose.js +105 -0
  21. package/src/cli/diagnose.test.js +124 -0
  22. package/src/cli/discover.js +9 -2
  23. package/src/cli/index.js +171 -9
  24. package/src/cli/process-utils.js +57 -0
  25. package/src/cli/process-utils.test.js +114 -0
  26. package/src/cli/review.js +391 -0
  27. package/src/cli/review.test.js +143 -0
  28. package/src/cli/scaffold.js +9 -4
  29. package/src/cli/stamp.js +102 -0
  30. package/src/cli/stamp.test.js +149 -0
  31. package/src/cli/start.js +15 -2
  32. package/src/cli/validate.js +184 -0
  33. package/src/cli/validate.test.js +153 -0
  34. package/src/mcp/mcp.test.js +261 -30
  35. package/src/mcp/server.js +207 -515
  36. package/src/mcp/validate-worker.js +18 -1
  37. package/src/runtime/index.js +144 -11
  38. package/src/runtime/runtime.test.js +199 -0
  39. package/src/runtime/ssr.js +13 -6
  40. package/src/runtime/ssr.test.js +31 -0
  41. package/src/server/index.js +162 -9
  42. package/src/server/server.test.js +225 -2
  43. package/src/spec/schema.js +1 -1
  44. package/src/ui/index.js +0 -5
  45. package/src/ui/ui.test.js +0 -75
  46. package/types/ui.d.ts +0 -21
  47. package/.claude/commands/build-page.md +0 -63
  48. package/.claude/commands/new-doc-page.md +0 -45
  49. package/.claude/commands/pulse-start.md +0 -11
  50. package/.claude/commands/pulse-stop.md +0 -5
  51. package/.claude/commands/verify.md +0 -118
  52. package/.claude/pulse-checklist.md +0 -189
  53. package/.github/workflows/ci.yml +0 -25
  54. package/.github/workflows/publish.yml +0 -45
  55. package/.pulse/load-reports/home/1773432711417.json +0 -22
  56. package/.pulse-verified +0 -1
  57. package/AGENTS.md +0 -697
  58. package/CLAUDE.md +0 -697
  59. package/README.md +0 -95
  60. package/benchmark/public/dist/counter.boot-PO6SNSJZ.js +0 -8
  61. package/benchmark/public/dist/home.boot-YK6VAEQI.js +0 -6
  62. package/benchmark/public/dist/manifest.json +0 -5
  63. package/benchmark/public/dist/runtime-5Y7ESAIB.js +0 -2
  64. package/benchmark/public/dist/runtime-KO4BHUQ3.js +0 -49
  65. package/benchmark/results/2026-03-27-09-20.json +0 -188
  66. package/benchmark/results/2026-03-27-09-21.json +0 -188
  67. package/benchmark/results/2026-03-27-09-29.json +0 -188
  68. package/benchmark/results/2026-03-27-10-14.json +0 -188
  69. package/benchmark/results/2026-03-27-10-18.json +0 -232
  70. package/benchmark/src/pages/counter.js +0 -37
  71. package/benchmark/src/pages/home.js +0 -21
  72. package/docs/.claude/commands/pulse-build.md +0 -7
  73. package/docs/.claude/commands/pulse-dev.md +0 -7
  74. package/docs/.claude/commands/pulse-start.md +0 -9
  75. package/docs/.claude/commands/pulse-stop.md +0 -5
  76. package/docs/.claude/commands/verify.md +0 -84
  77. package/docs/.claude/coverage-check.js +0 -196
  78. package/docs/.claude/pulse-checklist.md +0 -189
  79. package/docs/public/.pulse-ui-version +0 -1
  80. package/docs/public/andypixel.css +0 -237
  81. package/docs/public/bookworms.css +0 -422
  82. package/docs/public/coutumes.css +0 -561
  83. package/docs/public/docs.css +0 -2388
  84. package/docs/public/favicon.svg +0 -4
  85. package/docs/public/home-brut.css +0 -982
  86. package/docs/public/images/bookworms/hero-books.jpg +0 -0
  87. package/docs/public/images/bookworms/how-we-read-now.jpg +0 -0
  88. package/docs/public/images/bookworms/lantern-keepers.jpg +0 -0
  89. package/docs/public/images/bookworms/provincial-bookseller.jpg +0 -0
  90. package/docs/public/images/journey/178.jpg +0 -0
  91. package/docs/public/images/journey/29.jpg +0 -0
  92. package/docs/public/images/retrowheels/hero.jpg +0 -0
  93. package/docs/public/images/shop/devbook-pro.jpg +0 -0
  94. package/docs/public/images/suzukibento/1015.jpg +0 -0
  95. package/docs/public/images/suzukibento/1025.jpg +0 -0
  96. package/docs/public/images/suzukibento/1043.jpg +0 -0
  97. package/docs/public/images/suzukibento/1059.jpg +0 -0
  98. package/docs/public/images/suzukibento/1062.jpg +0 -0
  99. package/docs/public/images/suzukibento/1084.jpg +0 -0
  100. package/docs/public/images/swish/1469334031218-e382a71b716b.jpg +0 -0
  101. package/docs/public/images/swish/1483985988355-763728e1935b.jpg +0 -0
  102. package/docs/public/images/swish/1485518882345-15568b007407.jpg +0 -0
  103. package/docs/public/images/swish/1490481651871-ab68de25d43d.jpg +0 -0
  104. package/docs/public/images/swish/1496747611176-843222e1e57c.jpg +0 -0
  105. package/docs/public/images/swish/1502716119720-b23a93e5fe1b.jpg +0 -0
  106. package/docs/public/images/swish/1539109136881-3be0616acf4b.jpg +0 -0
  107. package/docs/public/images/swish/1551803091-e20673f15770.jpg +0 -0
  108. package/docs/public/images/swish/1558769132-cb1aea458c5e.jpg +0 -0
  109. package/docs/public/images/swish/1572804013309-59a88b7e92f1.jpg +0 -0
  110. package/docs/public/img/heic1501a.jpg +0 -0
  111. package/docs/public/img/placeholder-landscape.svg +0 -18
  112. package/docs/public/img/placeholder-square.svg +0 -14
  113. package/docs/public/img/placeholder-tall.svg +0 -22
  114. package/docs/public/journey.css +0 -528
  115. package/docs/public/menu.js +0 -327
  116. package/docs/public/pulse-ui.css +0 -3028
  117. package/docs/public/pulse-ui.js +0 -257
  118. package/docs/public/retrowheels.css +0 -425
  119. package/docs/public/shop.css +0 -269
  120. package/docs/public/sparkys.css +0 -382
  121. package/docs/public/suzukibento.css +0 -517
  122. package/docs/public/swish.css +0 -394
  123. package/docs/public/theme.css +0 -52
  124. package/docs/public/themes/lumio.css +0 -50
  125. package/docs/public/themes/observatory.css +0 -104
  126. package/docs/server.js +0 -161
  127. package/docs/src/lib/component-page.js +0 -49
  128. package/docs/src/lib/highlight.js +0 -2
  129. package/docs/src/lib/layout.js +0 -190
  130. package/docs/src/lib/metrics-store.js +0 -6
  131. package/docs/src/lib/nav.js +0 -171
  132. package/docs/src/lib/stats.js +0 -81
  133. package/docs/src/pages/accessibility.js +0 -157
  134. package/docs/src/pages/actions.js +0 -194
  135. package/docs/src/pages/auth.js +0 -176
  136. package/docs/src/pages/caching.js +0 -95
  137. package/docs/src/pages/components/accordion.js +0 -48
  138. package/docs/src/pages/components/alert.js +0 -35
  139. package/docs/src/pages/components/app-badge.js +0 -41
  140. package/docs/src/pages/components/avatar.js +0 -35
  141. package/docs/src/pages/components/badge.js +0 -36
  142. package/docs/src/pages/components/breadcrumbs.js +0 -94
  143. package/docs/src/pages/components/button.js +0 -84
  144. package/docs/src/pages/components/card.js +0 -225
  145. package/docs/src/pages/components/charts.js +0 -283
  146. package/docs/src/pages/components/checkbox.js +0 -129
  147. package/docs/src/pages/components/cluster.js +0 -47
  148. package/docs/src/pages/components/container.js +0 -40
  149. package/docs/src/pages/components/cta.js +0 -54
  150. package/docs/src/pages/components/divider.js +0 -37
  151. package/docs/src/pages/components/empty.js +0 -36
  152. package/docs/src/pages/components/feature.js +0 -60
  153. package/docs/src/pages/components/fieldset.js +0 -65
  154. package/docs/src/pages/components/fileupload.js +0 -128
  155. package/docs/src/pages/components/footer.js +0 -58
  156. package/docs/src/pages/components/grid.js +0 -169
  157. package/docs/src/pages/components/heading.js +0 -107
  158. package/docs/src/pages/components/hero.js +0 -123
  159. package/docs/src/pages/components/icons.js +0 -410
  160. package/docs/src/pages/components/image.js +0 -71
  161. package/docs/src/pages/components/input.js +0 -51
  162. package/docs/src/pages/components/media.js +0 -51
  163. package/docs/src/pages/components/modal.js +0 -111
  164. package/docs/src/pages/components/nav.js +0 -136
  165. package/docs/src/pages/components/phone-frame.js +0 -74
  166. package/docs/src/pages/components/progress.js +0 -102
  167. package/docs/src/pages/components/prose.js +0 -111
  168. package/docs/src/pages/components/pullquote.js +0 -71
  169. package/docs/src/pages/components/radio.js +0 -194
  170. package/docs/src/pages/components/rating.js +0 -106
  171. package/docs/src/pages/components/search.js +0 -61
  172. package/docs/src/pages/components/section.js +0 -98
  173. package/docs/src/pages/components/segmented.js +0 -121
  174. package/docs/src/pages/components/select.js +0 -45
  175. package/docs/src/pages/components/slider.js +0 -115
  176. package/docs/src/pages/components/spinner.js +0 -73
  177. package/docs/src/pages/components/stack.js +0 -48
  178. package/docs/src/pages/components/stat.js +0 -55
  179. package/docs/src/pages/components/stepper.js +0 -66
  180. package/docs/src/pages/components/table.js +0 -45
  181. package/docs/src/pages/components/textarea.js +0 -31
  182. package/docs/src/pages/components/toggle.js +0 -84
  183. package/docs/src/pages/components/tooltip.js +0 -48
  184. package/docs/src/pages/components.js +0 -200
  185. package/docs/src/pages/config.js +0 -193
  186. package/docs/src/pages/constraints.js +0 -100
  187. package/docs/src/pages/deploy.js +0 -233
  188. package/docs/src/pages/error-pages.js +0 -99
  189. package/docs/src/pages/extending.js +0 -198
  190. package/docs/src/pages/faq.js +0 -99
  191. package/docs/src/pages/forms.js +0 -129
  192. package/docs/src/pages/getting-started.js +0 -147
  193. package/docs/src/pages/guard.js +0 -120
  194. package/docs/src/pages/home.js +0 -350
  195. package/docs/src/pages/how-it-works.js +0 -244
  196. package/docs/src/pages/hydration.js +0 -108
  197. package/docs/src/pages/images.js +0 -120
  198. package/docs/src/pages/markdown.js +0 -156
  199. package/docs/src/pages/meta.js +0 -193
  200. package/docs/src/pages/mutations.js +0 -107
  201. package/docs/src/pages/navigation.js +0 -85
  202. package/docs/src/pages/performance.js +0 -157
  203. package/docs/src/pages/persist.js +0 -89
  204. package/docs/src/pages/project-structure.js +0 -91
  205. package/docs/src/pages/prompt-examples.js +0 -186
  206. package/docs/src/pages/raw-responses.js +0 -122
  207. package/docs/src/pages/routing.js +0 -120
  208. package/docs/src/pages/server-api.js +0 -291
  209. package/docs/src/pages/server-data.js +0 -203
  210. package/docs/src/pages/slash-commands.js +0 -55
  211. package/docs/src/pages/spec.js +0 -207
  212. package/docs/src/pages/state.js +0 -102
  213. package/docs/src/pages/store.js +0 -203
  214. package/docs/src/pages/streaming.js +0 -107
  215. package/docs/src/pages/stripe.js +0 -194
  216. package/docs/src/pages/supabase.js +0 -324
  217. package/docs/src/pages/templates/andypixel.js +0 -260
  218. package/docs/src/pages/templates/blog-post.js +0 -226
  219. package/docs/src/pages/templates/bookworms.js +0 -227
  220. package/docs/src/pages/templates/coutumes.js +0 -237
  221. package/docs/src/pages/templates/journey.js +0 -186
  222. package/docs/src/pages/templates/mobile-app.js +0 -371
  223. package/docs/src/pages/templates/retrowheels.js +0 -355
  224. package/docs/src/pages/templates/shop.js +0 -268
  225. package/docs/src/pages/templates/sparkys.js +0 -264
  226. package/docs/src/pages/templates/suzukibento.js +0 -200
  227. package/docs/src/pages/templates/swish.js +0 -313
  228. package/docs/src/pages/templates.js +0 -534
  229. package/docs/src/pages/testing.js +0 -199
  230. package/docs/src/pages/validation.js +0 -138
  231. package/examples/.claude/commands/pulse-build.md +0 -7
  232. package/examples/.claude/commands/pulse-dev.md +0 -7
  233. package/examples/.claude/commands/pulse-start.md +0 -9
  234. package/examples/.claude/commands/pulse-stop.md +0 -5
  235. package/examples/.claude/commands/verify.md +0 -84
  236. package/examples/.claude/coverage-check.js +0 -196
  237. package/examples/.claude/pulse-checklist.md +0 -189
  238. package/examples/andypixel.css +0 -237
  239. package/examples/andypixel.js +0 -258
  240. package/examples/bookworms.css +0 -422
  241. package/examples/bookworms.js +0 -225
  242. package/examples/contact.js +0 -166
  243. package/examples/counter.js +0 -94
  244. package/examples/dev.server.js +0 -110
  245. package/examples/examples.test.js +0 -394
  246. package/examples/journey.css +0 -528
  247. package/examples/journey.js +0 -186
  248. package/examples/pricing.js +0 -262
  249. package/examples/products.js +0 -190
  250. package/examples/public/.pulse-ui-version +0 -1
  251. package/examples/public/pulse-ui.css +0 -3028
  252. package/examples/public/pulse-ui.js +0 -257
  253. package/examples/quiz.js +0 -208
  254. package/examples/retrowheels.css +0 -425
  255. package/examples/retrowheels.js +0 -353
  256. package/examples/shared.js +0 -84
  257. package/examples/suzukibento.css +0 -513
  258. package/examples/suzukibento.js +0 -200
  259. package/examples/swish.css +0 -394
  260. package/examples/swish.js +0 -311
  261. package/examples/todos.js +0 -162
  262. package/src/ui/fieldset.js +0 -37
  263. package/src/ui/phone-frame.js +0 -40
  264. package/src/ui/search.js +0 -78
  265. package/src/ui/segmented.js +0 -50
  266. package/src/ui/tooltip.js +0 -28
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Pulse — diagnose.js tests
3
+ * run: node src/cli/diagnose.test.js
4
+ *
5
+ * Shared logic behind pulse_diagnose/pulse_resolve_error (MCP tools) and
6
+ * `pulse diagnose`/`pulse resolve-error` (CLI commands) — a human running
7
+ * the CLI must see the exact same journal an agent sees.
8
+ */
9
+
10
+ import { test } from 'node:test'
11
+ import assert from 'node:assert/strict'
12
+ import fs from 'node:fs'
13
+ import os from 'node:os'
14
+ import path from 'node:path'
15
+ import { readJournal, formatJournal, resolveEntries, formatResolveResult } from './diagnose.js'
16
+
17
+ function tmpProject() {
18
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-diagnose-test-'))
19
+ }
20
+
21
+ function writeJournal(root, entries) {
22
+ fs.mkdirSync(path.join(root, '.pulse'), { recursive: true })
23
+ fs.writeFileSync(path.join(root, '.pulse', 'errors.json'), JSON.stringify(entries, null, 2))
24
+ }
25
+
26
+ function sampleEntry(overrides = {}) {
27
+ return {
28
+ id: 'abc-123', ts: '2026-01-01T00:00:00.000Z', route: '/dashboard',
29
+ phase: 'view', message: 'boom', stack: 'Error: boom', resolved: false,
30
+ ...overrides,
31
+ }
32
+ }
33
+
34
+ test('readJournal reports exists:false when no journal file exists', () => {
35
+ const root = tmpProject()
36
+ const result = readJournal(root)
37
+ assert.equal(result.exists, false)
38
+ assert.deepEqual(result.entries, [])
39
+ fs.rmSync(root, { recursive: true, force: true })
40
+ })
41
+
42
+ test('readJournal returns unresolved entries by default', () => {
43
+ const root = tmpProject()
44
+ writeJournal(root, [sampleEntry({ id: 'a', resolved: false }), sampleEntry({ id: 'b', resolved: true })])
45
+ const result = readJournal(root)
46
+ assert.equal(result.entries.length, 1)
47
+ assert.equal(result.entries[0].id, 'a')
48
+ fs.rmSync(root, { recursive: true, force: true })
49
+ })
50
+
51
+ test('readJournal with includeResolved:true returns both', () => {
52
+ const root = tmpProject()
53
+ writeJournal(root, [sampleEntry({ id: 'a', resolved: false }), sampleEntry({ id: 'b', resolved: true })])
54
+ const result = readJournal(root, { includeResolved: true })
55
+ assert.equal(result.entries.length, 2)
56
+ fs.rmSync(root, { recursive: true, force: true })
57
+ })
58
+
59
+ test('readJournal filters by route', () => {
60
+ const root = tmpProject()
61
+ writeJournal(root, [
62
+ sampleEntry({ id: 'a', route: '/dashboard' }),
63
+ sampleEntry({ id: 'b', route: '/other' }),
64
+ ])
65
+ const result = readJournal(root, { route: '/dashboard' })
66
+ assert.equal(result.entries.length, 1)
67
+ assert.equal(result.entries[0].id, 'a')
68
+ fs.rmSync(root, { recursive: true, force: true })
69
+ })
70
+
71
+ test('formatJournal reports a clean message when nothing is recorded', () => {
72
+ const root = tmpProject()
73
+ const result = readJournal(root)
74
+ assert.match(formatJournal(result), /does not exist/)
75
+ fs.rmSync(root, { recursive: true, force: true })
76
+ })
77
+
78
+ test('formatJournal includes the route, phase, message, and stack for each entry', () => {
79
+ const root = tmpProject()
80
+ writeJournal(root, [sampleEntry()])
81
+ const result = readJournal(root)
82
+ const output = formatJournal(result)
83
+ assert.match(output, /\/dashboard/)
84
+ assert.match(output, /view/)
85
+ assert.match(output, /boom/)
86
+ assert.match(output, /Error: boom/)
87
+ fs.rmSync(root, { recursive: true, force: true })
88
+ })
89
+
90
+ test('resolveEntries by id marks only the matching entry', () => {
91
+ const root = tmpProject()
92
+ writeJournal(root, [sampleEntry({ id: 'a' }), sampleEntry({ id: 'b' })])
93
+ const result = resolveEntries(root, { id: 'a' })
94
+ assert.equal(result.count, 1)
95
+ const after = JSON.parse(fs.readFileSync(path.join(root, '.pulse', 'errors.json'), 'utf8'))
96
+ assert.equal(after.find(e => e.id === 'a').resolved, true)
97
+ assert.equal(after.find(e => e.id === 'b').resolved, false)
98
+ fs.rmSync(root, { recursive: true, force: true })
99
+ })
100
+
101
+ test('resolveEntries by route marks every unresolved entry on that route', () => {
102
+ const root = tmpProject()
103
+ writeJournal(root, [
104
+ sampleEntry({ id: 'a', route: '/x' }),
105
+ sampleEntry({ id: 'b', route: '/x' }),
106
+ sampleEntry({ id: 'c', route: '/y' }),
107
+ ])
108
+ const result = resolveEntries(root, { route: '/x' })
109
+ assert.equal(result.count, 2)
110
+ fs.rmSync(root, { recursive: true, force: true })
111
+ })
112
+
113
+ test('resolveEntries with neither id nor route returns an error', () => {
114
+ const root = tmpProject()
115
+ const result = resolveEntries(root, {})
116
+ assert.match(result.error, /pass either id or route/)
117
+ fs.rmSync(root, { recursive: true, force: true })
118
+ })
119
+
120
+ test('formatResolveResult reports the count or "no matching entries"', () => {
121
+ assert.match(formatResolveResult({ count: 2, error: null }), /Resolved 2 entries/)
122
+ assert.match(formatResolveResult({ count: 1, error: null }), /Resolved 1 entry\./)
123
+ assert.match(formatResolveResult({ count: 0, error: null }), /No matching unresolved entries/)
124
+ })
@@ -91,9 +91,16 @@ export async function loadPages(projectRoot, bust = 0) {
91
91
  }
92
92
 
93
93
  // Auto-set hydrate only for pages that need client-side JS.
94
- // Purely SSR pages (no mutations, actions, persist) ship zero JS.
94
+ // Purely SSR pages (no mutations, actions, persist, store) ship zero JS.
95
95
  // spec.hydrate wins if explicitly provided.
96
- const needsHydration = spec.hydrate || spec.mutations || spec.actions || spec.persist
96
+ //
97
+ // spec.store alone must count as a hydration need even with no local
98
+ // mutations/actions: mount() is what wires up data-store-event dispatch
99
+ // AND the live SSE store-push subscription (initLiveStore) — a
100
+ // store-only page with no client bundle would silently never receive
101
+ // pushStore() broadcasts and have a dead data-store-event button, with
102
+ // no error surfaced anywhere (server-rendered HTML looks correct).
103
+ const needsHydration = spec.hydrate || spec.mutations || spec.actions || spec.persist || spec.store?.length
97
104
  const hydrateUrl = needsHydration
98
105
  ? spec.hydrate || ('/src/pages/' + path.relative(
99
106
  path.join(projectRoot, 'src', 'pages'),
package/src/cli/index.js CHANGED
@@ -7,6 +7,12 @@
7
7
  * pulse dev dev server only (no AI)
8
8
  * pulse build production build → public/dist/
9
9
  * pulse start production server (requires prior build)
10
+ * pulse validate <file> validate a spec — same check pulse_validate (MCP) runs, no agent needed
11
+ * pulse diagnose [--route </path>] [--include-resolved] read the dev-only error journal
12
+ * pulse resolve-error --id <id> | --route </path> mark error journal entries resolved
13
+ * pulse stamp [--route </path>] [--full|--quick] write .pulse-verified — same check pulse_stamp (MCP) runs
14
+ * pulse check-bundles inspect public/dist/ bundle contents (requires prior build)
15
+ * pulse review <file> [--quick] run the mechanized review checklist — same check pulse_review (MCP) runs
10
16
  * pulse update re-copy pulse-ui.css/js from installed package → public/
11
17
  * pulse --version print installed version and exit
12
18
  * pulse -v alias for --version
@@ -18,6 +24,7 @@
18
24
  import path from 'path'
19
25
  import fs from 'fs'
20
26
  import { scaffold } from './scaffold.js'
27
+ import { killPulseServerOnPort } from './process-utils.js'
21
28
 
22
29
  // Extract --agent flag before routing, so it works alongside any subcommand
23
30
  const rawArgs = process.argv.slice(2)
@@ -51,9 +58,11 @@ function isPulseProject(dir) {
51
58
  async function runDev(root) {
52
59
  const devScript = new URL('./dev.js', import.meta.url).pathname
53
60
  const { spawn } = await import('child_process')
61
+ // Forward any extra flags (e.g. --port 3002) to the dev script
62
+ const extraArgs = args.slice(1)
54
63
  const proc = spawn(
55
64
  process.execPath,
56
- [devScript, '--root', root],
65
+ [devScript, '--root', root, ...extraArgs],
57
66
  { stdio: 'inherit' }
58
67
  )
59
68
  proc.on('exit', code => process.exit(code ?? 0))
@@ -74,6 +83,124 @@ async function runBuild(root) {
74
83
  proc.on('exit', code => process.exit(code ?? 0))
75
84
  }
76
85
 
86
+ // ---------------------------------------------------------------------------
87
+ // pulse validate — the same check pulse_validate (MCP) runs, reachable
88
+ // without an agent. Shares validate.js's logic exactly — never a second
89
+ // implementation that can drift from what an agent sees.
90
+ // ---------------------------------------------------------------------------
91
+
92
+ async function runValidate(root, fileArg) {
93
+ const { validateFile, formatValidationResult } = await import('./validate.js')
94
+
95
+ if (!fileArg) {
96
+ console.error('Usage: pulse validate <file>\n <file> is a path to a spec file, relative to the current directory or absolute.')
97
+ process.exit(1)
98
+ }
99
+
100
+ const file = path.isAbsolute(fileArg) ? fileArg : path.resolve(root, fileArg)
101
+ const result = await validateFile(file, root)
102
+ console.log(formatValidationResult(result))
103
+ process.exit(result.valid ? 0 : 1)
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // pulse diagnose / pulse resolve-error — the dev-only error journal, reachable
108
+ // without an agent. Shares diagnose.js's logic exactly with pulse_diagnose
109
+ // and pulse_resolve_error (MCP tools).
110
+ // ---------------------------------------------------------------------------
111
+
112
+ function flagValue(subArgs, name) {
113
+ const i = subArgs.indexOf(name)
114
+ return i !== -1 ? subArgs[i + 1] : undefined
115
+ }
116
+
117
+ async function runDiagnose(root, subArgs) {
118
+ const { readJournal, formatJournal } = await import('./diagnose.js')
119
+ const route = flagValue(subArgs, '--route')
120
+ const includeResolved = subArgs.includes('--include-resolved')
121
+
122
+ const result = readJournal(root, { route, includeResolved })
123
+ console.log(formatJournal(result, { route, includeResolved }))
124
+ process.exit(result.error ? 1 : 0)
125
+ }
126
+
127
+ async function runResolveError(root, subArgs) {
128
+ const { resolveEntries, formatResolveResult } = await import('./diagnose.js')
129
+ const id = flagValue(subArgs, '--id')
130
+ const route = flagValue(subArgs, '--route')
131
+
132
+ if (!id && !route) {
133
+ console.error('Usage: pulse resolve-error --id <id>\n or: pulse resolve-error --route </path>')
134
+ process.exit(1)
135
+ }
136
+
137
+ const result = resolveEntries(root, { id, route })
138
+ console.log(formatResolveResult(result))
139
+ process.exit(result.error ? 1 : 0)
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // pulse stamp — write .pulse-verified, same check pulse_stamp (MCP) runs
144
+ // ---------------------------------------------------------------------------
145
+
146
+ async function runStamp(root, subArgs) {
147
+ const { writeStamp, formatStampResult } = await import('./stamp.js')
148
+ const route = flagValue(subArgs, '--route')
149
+ const mode = subArgs.includes('--full') ? 'full' : subArgs.includes('--quick') ? 'quick' : undefined
150
+
151
+ const result = writeStamp(root, { route, mode })
152
+ console.log(formatStampResult(result, { agentFacing: false }))
153
+ process.exit(result.stamped ? 0 : 1)
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // pulse check-bundles — inspect production bundle contents (not just sizes),
158
+ // reachable without an agent. Shares check-bundles.js's logic exactly with
159
+ // pulse_check_bundles (MCP tool). Requires a prior `pulse build`.
160
+ // ---------------------------------------------------------------------------
161
+
162
+ async function runCheckBundles(root) {
163
+ const { checkBundles, formatBundleCheck } = await import('./check-bundles.js')
164
+ const result = checkBundles(root)
165
+ console.log(formatBundleCheck(result))
166
+ process.exit(result.error || result.issues.length > 0 ? 1 : 0)
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // pulse review — the same checklist pulse_review (MCP) runs, reachable
171
+ // without an agent. Shares review.js's logic exactly — the report is
172
+ // reformatted for a human reader (no "you are now a reviewer" framing,
173
+ // no "continue to the verification workflow" — that's agent-only guidance).
174
+ // ---------------------------------------------------------------------------
175
+
176
+ async function runReview(root, subArgs) {
177
+ const quick = subArgs.includes('--quick')
178
+ const fileArg = subArgs.find(a => !a.startsWith('--'))
179
+
180
+ if (!fileArg) {
181
+ console.error('Usage: pulse review <file> [--quick]\n <file> is a path to a spec file, relative to the current directory or absolute.')
182
+ process.exit(1)
183
+ }
184
+
185
+ const { runQuickReview, formatQuickReview, runFullReview, formatFullReview } = await import('./review.js')
186
+ const file = path.isAbsolute(fileArg) ? fileArg : path.resolve(root, fileArg)
187
+ if (!fs.existsSync(file)) {
188
+ console.error(`File not found: ${file}`)
189
+ process.exit(1)
190
+ }
191
+ const source = fs.readFileSync(file, 'utf8')
192
+
193
+ if (quick) {
194
+ const result = await runQuickReview(source, file)
195
+ console.log(formatQuickReview(result, { agentFacing: false }))
196
+ process.exit(result.issues.length > 0 ? 1 : 0)
197
+ } else {
198
+ const result = await runFullReview(source, file)
199
+ console.log(formatFullReview(result, { source, file, agentFacing: false }))
200
+ process.exit(result.validationResult.includes('✓') ? 0 : 1)
201
+ }
202
+ }
203
+
77
204
  // ---------------------------------------------------------------------------
78
205
  // pulse (no subcommand) — scaffold or start AI session
79
206
  // ---------------------------------------------------------------------------
@@ -259,7 +386,6 @@ async function launchCopilotSession(root, mcpServerPath, spawn, os) {
259
386
  // ---------------------------------------------------------------------------
260
387
 
261
388
  async function runStop(root) {
262
- const { execSync } = await import('child_process')
263
389
  let port = 3000
264
390
  const configPath = path.join(root, 'pulse.config.js')
265
391
  if (fs.existsSync(configPath)) {
@@ -268,13 +394,25 @@ async function runStop(root) {
268
394
  if (mod.default?.port) port = mod.default.port
269
395
  } catch { /* use default */ }
270
396
  }
271
- try {
272
- // Kill the dev server AND the local production server (dev port + 1) —
273
- // pulse_build spawns the prod server detached, so without this it lingers
274
- // after the session and blocks the next build's Lighthouse run.
275
- execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null; lsof -ti:${port + 1} | xargs kill -9 2>/dev/null; true`, { stdio: 'inherit' })
276
- console.log(`\n⚡ Servers on ports ${port} (dev) and ${port + 1} (prod) stopped.\n`)
277
- } catch { /* nothing was running */ }
397
+ // Kill the dev server AND the local production server (dev port + 1) —
398
+ // pulse_build spawns the prod server detached, so without this it lingers
399
+ // after the session and blocks the next build's Lighthouse run. Only
400
+ // kills a process that's actually a Pulse server — see
401
+ // killPulseServerOnPort's doc comment for why: a blind lsof|kill on the
402
+ // port used to kill whatever else happened to be listening there.
403
+ const dev = killPulseServerOnPort(port)
404
+ const prod = killPulseServerOnPort(port + 1)
405
+ const stopped = [
406
+ ...(dev.killed.length ? [`${port} (dev)`] : []),
407
+ ...(prod.killed.length ? [`${port + 1} (prod)`] : []),
408
+ ]
409
+ if (stopped.length > 0) console.log(`\n⚡ Server${stopped.length > 1 ? 's' : ''} on ${stopped.join(' and ')} stopped.\n`)
410
+ for (const skipped of [...dev.skipped, ...prod.skipped]) {
411
+ console.log(`⚠ Left PID ${skipped.pid} running — not a Pulse server: ${skipped.command}`)
412
+ }
413
+ if (stopped.length === 0 && dev.skipped.length === 0 && prod.skipped.length === 0) {
414
+ console.log('\nNothing running on those ports.\n')
415
+ }
278
416
  }
279
417
 
280
418
  // ---------------------------------------------------------------------------
@@ -475,6 +613,12 @@ switch (command) {
475
613
  ${c.cyan('dev')} start the dev server
476
614
  ${c.cyan('build')} production build ${c.dim('→ public/dist/')}
477
615
  ${c.cyan('start')} production server ${c.dim('(requires prior build)')}
616
+ ${c.cyan('validate')} ${c.dim('<file>')} validate a spec — the same check an agent runs, without an agent
617
+ ${c.cyan('diagnose')} read the dev-only error journal ${c.dim('(.pulse/errors.json)')}
618
+ ${c.cyan('resolve-error')} mark error journal entries resolved
619
+ ${c.cyan('stamp')} write .pulse-verified ${c.dim('[--route </path>] [--full|--quick]')}
620
+ ${c.cyan('check-bundles')} inspect public/dist/ bundle contents ${c.dim('(requires prior build)')}
621
+ ${c.cyan('review')} ${c.dim('<file>')} run the mechanized review checklist ${c.dim('[--quick]')}
478
622
  ${c.cyan('update')} re-copy pulse-ui assets from installed package
479
623
 
480
624
  ${c.bold('Options:')}
@@ -503,6 +647,24 @@ switch (command) {
503
647
  case 'start':
504
648
  await runStart(CWD)
505
649
  break
650
+ case 'validate':
651
+ await runValidate(CWD, args[1])
652
+ break
653
+ case 'diagnose':
654
+ await runDiagnose(CWD, args.slice(1))
655
+ break
656
+ case 'resolve-error':
657
+ await runResolveError(CWD, args.slice(1))
658
+ break
659
+ case 'stamp':
660
+ await runStamp(CWD, args.slice(1))
661
+ break
662
+ case 'check-bundles':
663
+ await runCheckBundles(CWD)
664
+ break
665
+ case 'review':
666
+ await runReview(CWD, args.slice(1))
667
+ break
506
668
  case 'report-server':
507
669
  await runReportServer(CWD)
508
670
  break
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Pulse — process management helpers
3
+ *
4
+ * Shared between the MCP server (pulse_restart_server, pulse_build) and the
5
+ * CLI (`pulse stop`) — both need to free a port before starting a server,
6
+ * and both previously did it with a blind `lsof -ti:PORT | xargs kill -9`.
7
+ */
8
+
9
+ import { execFileSync } from 'child_process'
10
+
11
+ /** Command-line substrings that identify a process as a Pulse server. */
12
+ export const PULSE_SERVER_MARKERS = ['cli/dev.js', 'cli/start.js', 'scripts/build.js']
13
+
14
+ /**
15
+ * Kill only processes on `port` that are actually a Pulse dev/prod server —
16
+ * never an unconditional `lsof -ti:PORT | xargs kill -9`.
17
+ *
18
+ * That blind pattern used to be at every restart/build/stop call site: it
19
+ * kills whatever happens to be listening on the configured port, no
20
+ * ownership check at all. On a machine running other projects, a plain
21
+ * port collision (an unrelated Next.js/Vite/Rails dev server left running
22
+ * on 3000, say) means one Pulse command silently kills someone else's
23
+ * server. Found via a real user report: pulse_fetch_page returned a stale
24
+ * 500 from an unrelated project on port 3000, then the next
25
+ * pulse_restart_server call — which still ran the blind kill — coincided
26
+ * with the MCP connection itself dropping.
27
+ *
28
+ * Only kills a PID whose command line contains one of PULSE_SERVER_MARKERS
29
+ * — anything else on the port is left alone and reported back instead.
30
+ *
31
+ * @param {number} port
32
+ * @returns {{ killed: string[], skipped: { pid: string, command: string }[] }}
33
+ */
34
+ export function killPulseServerOnPort(port) {
35
+ const result = { killed: [], skipped: [] }
36
+ let pids
37
+ try {
38
+ pids = execFileSync('sh', ['-c', `lsof -ti:${port} 2>/dev/null`], { encoding: 'utf8' })
39
+ .split('\n').map(s => s.trim()).filter(Boolean)
40
+ } catch {
41
+ return result // lsof found nothing — nothing to do
42
+ }
43
+ for (const pid of pids) {
44
+ let command = ''
45
+ try {
46
+ command = execFileSync('sh', ['-c', `ps -o command= -p ${pid} 2>/dev/null`], { encoding: 'utf8' }).trim()
47
+ } catch { continue } // process gone between lsof and ps — nothing to kill
48
+ if (!command) continue
49
+ if (PULSE_SERVER_MARKERS.some(marker => command.includes(marker))) {
50
+ try { execFileSync('sh', ['-c', `kill -9 ${pid} 2>/dev/null`]) } catch { /* already gone */ }
51
+ result.killed.push(pid)
52
+ } else {
53
+ result.skipped.push({ pid, command })
54
+ }
55
+ }
56
+ return result
57
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Pulse — process-utils.js tests
3
+ * run: node src/cli/process-utils.test.js
4
+ *
5
+ * killPulseServerOnPort must never kill a process it didn't spawn as a
6
+ * Pulse dev/prod server — see its doc comment for the real user report
7
+ * (an unrelated Next.js dev server on port 3000 was at risk of being
8
+ * killed by a blind lsof|kill) this fixes.
9
+ */
10
+
11
+ import { test } from 'node:test'
12
+ import assert from 'node:assert/strict'
13
+ import { spawn } from 'node:child_process'
14
+ import net from 'node:net'
15
+ import { killPulseServerOnPort } from './process-utils.js'
16
+
17
+ /** Find a free TCP port by binding to port 0 and reading it back. */
18
+ async function freePort() {
19
+ return new Promise((resolve, reject) => {
20
+ const srv = net.createServer()
21
+ srv.listen(0, () => {
22
+ const { port } = srv.address()
23
+ srv.close(() => resolve(port))
24
+ })
25
+ srv.on('error', reject)
26
+ })
27
+ }
28
+
29
+ /** Spawn a plain (non-Pulse) process holding a port open, for negative tests. */
30
+ function spawnPlainListener(port) {
31
+ const proc = spawn(process.execPath, ['-e', `
32
+ const net = require('net')
33
+ net.createServer().listen(${port})
34
+ setInterval(() => {}, 1000) // keep the process alive
35
+ `])
36
+ return proc
37
+ }
38
+
39
+ async function waitUntilListening(port, maxMs = 3000) {
40
+ const deadline = Date.now() + maxMs
41
+ while (Date.now() < deadline) {
42
+ const alive = await new Promise(resolve => {
43
+ const sock = net.createConnection(port, '127.0.0.1')
44
+ sock.on('connect', () => { sock.destroy(); resolve(true) })
45
+ sock.on('error', () => resolve(false))
46
+ })
47
+ if (alive) return true
48
+ await new Promise(r => setTimeout(r, 50))
49
+ }
50
+ return false
51
+ }
52
+
53
+ async function waitUntilFree(port, maxMs = 3000) {
54
+ const deadline = Date.now() + maxMs
55
+ while (Date.now() < deadline) {
56
+ const alive = await new Promise(resolve => {
57
+ const sock = net.createConnection(port, '127.0.0.1')
58
+ sock.on('connect', () => { sock.destroy(); resolve(true) })
59
+ sock.on('error', () => resolve(false))
60
+ })
61
+ if (!alive) return true
62
+ await new Promise(r => setTimeout(r, 50))
63
+ }
64
+ return false
65
+ }
66
+
67
+ test('killPulseServerOnPort does not kill a process whose command line has no Pulse marker', async () => {
68
+ const port = await freePort()
69
+ const proc = spawnPlainListener(port)
70
+ try {
71
+ assert.equal(await waitUntilListening(port), true, 'test listener never came up')
72
+
73
+ const result = killPulseServerOnPort(port)
74
+
75
+ assert.equal(result.killed.length, 0, 'must not report the unrelated process as killed')
76
+ assert.equal(result.skipped.length, 1, 'must report exactly one skipped process')
77
+ assert.equal(result.skipped[0].pid, String(proc.pid))
78
+ // The process must actually still be alive — not just unreported as killed.
79
+ assert.equal(await waitUntilFree(port, 300), false, 'the unrelated process must still be listening')
80
+ } finally {
81
+ proc.kill('SIGKILL')
82
+ }
83
+ })
84
+
85
+ test('killPulseServerOnPort kills a process whose command line matches a Pulse marker', async () => {
86
+ const port = await freePort()
87
+ // Simulate a Pulse dev server by giving the child process's argv a marker
88
+ // substring (cli/dev.js) without actually running the real dev server —
89
+ // exercises the same ps-based matching path a real `node .../cli/dev.js`
90
+ // invocation would hit.
91
+ const proc = spawn(process.execPath, ['-e', `
92
+ const net = require('net')
93
+ net.createServer().listen(${port})
94
+ setInterval(() => {}, 1000)
95
+ `, '--', 'cli/dev.js'])
96
+ try {
97
+ assert.equal(await waitUntilListening(port), true, 'test listener never came up')
98
+
99
+ const result = killPulseServerOnPort(port)
100
+
101
+ assert.equal(result.skipped.length, 0, 'must not skip a process matching a Pulse marker')
102
+ assert.equal(result.killed.length, 1)
103
+ assert.equal(result.killed[0], String(proc.pid))
104
+ assert.equal(await waitUntilFree(port), true, 'the matched process must actually be killed')
105
+ } finally {
106
+ try { proc.kill('SIGKILL') } catch { /* already dead */ }
107
+ }
108
+ })
109
+
110
+ test('killPulseServerOnPort on a free port returns empty killed and skipped', async () => {
111
+ const port = await freePort()
112
+ const result = killPulseServerOnPort(port)
113
+ assert.deepEqual(result, { killed: [], skipped: [] })
114
+ })