@invisibleloop/pulse 0.20.1 → 0.20.3

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.
package/src/mcp/server.js CHANGED
@@ -5,11 +5,12 @@
5
5
  * Provides tools and resources for an AI agent working inside a Pulse project.
6
6
  *
7
7
  * Resources:
8
+ * pulse://start — context-aware entry point (new project / edit / bug fix)
8
9
  * pulse://guide — complete guide: spec format, UI components, CSS rules, patterns
9
10
  *
10
11
  * Tools:
11
12
  * pulse_intent — intent engine: describe what to build, get archetype + scaffold
12
- * pulse_suggest — draft-mode contextual feedback on partial specs
13
+ * pulse_suggest — draft-mode contextual feedback on partial specs (use after first draft)
13
14
  * pulse_intake — product intake: capture app details before scaffolding
14
15
  * pulse_sketch — generate 3 structural layout directions before writing code
15
16
  * pulse_list_icons — list all available icon names, grouped by category
@@ -17,7 +18,9 @@
17
18
  * pulse_list_structure — list all pages and components
18
19
  * pulse_create_page — create a new page spec with proper template
19
20
  * pulse_create_component — create a reusable component
21
+ * pulse_create_tests — generate starter test file with XSS, null data, and landmark stubs
20
22
  * pulse_validate — validate a spec against the schema
23
+ * pulse_review — full code review (pass quick:true for lightweight mid-build check)
21
24
  * pulse_stamp — write the .pulse-verified stamp (call as last step of /verify)
22
25
  * pulse_check_version — installed vs static vs npm latest
23
26
  * pulse_update — re-copy pulse-ui assets from package → public/
@@ -188,6 +191,99 @@ for (const { name, uri, title, description, content } of GUIDE_RESOURCES) {
188
191
  )
189
192
  }
190
193
 
194
+ // pulse://start — context-aware single entry point.
195
+ // Detects whether this is a new project, new page, or targeted edit, and returns
196
+ // exactly what the agent needs — no upfront decision tree, no wasted fetches.
197
+ server.registerResource(
198
+ 'start',
199
+ 'pulse://start',
200
+ {
201
+ title: 'Pulse Start — Context-Aware Entry Point',
202
+ description: 'Single entry point that returns the right resources for your situation: new project, new page, targeted edit, or bug fix. Fetch this first instead of deciding between pulse://workflow and pulse://quickstart.',
203
+ mimeType: 'text/plain',
204
+ },
205
+ async () => {
206
+ // Detect project state to tailor the response
207
+ let pageCount = 0
208
+ try {
209
+ const specs = await loadPages(ROOT)
210
+ pageCount = specs.length
211
+ } catch { /* fresh project */ }
212
+
213
+ const isNewProject = pageCount === 0
214
+
215
+ return ({
216
+ contents: [{
217
+ uri: 'pulse://start',
218
+ mimeType: 'text/plain',
219
+ text: `# Pulse — Start Here
220
+
221
+ ${isNewProject
222
+ ? `> **New project detected** — no pages found in src/pages/ yet.`
223
+ : `> **Existing project** — ${pageCount} page${pageCount !== 1 ? 's' : ''} found.`}
224
+
225
+ ---
226
+
227
+ ## What are you doing?
228
+
229
+ ### A — New page or new site from scratch
230
+ Fetch \`pulse://workflow\` for the full phase/gate sequence, then follow the intake → sketch → intent pipeline.
231
+
232
+ Quick checklist before your first line of code:
233
+ 1. Ask the user for design inspiration first (a site, screenshot, or mood board)
234
+ 2. Ask: **light or dark?** Pulse renders dark when \`meta.theme\` is unset — decide before writing a single line
235
+ 3. Run \`pulse_intake\` → \`pulse_sketch\` → \`pulse_intent\`
236
+ 4. Fetch \`pulse://guide/templates\` + \`pulse://guide/design-references\` for aesthetic direction
237
+
238
+ ### B — Editing an existing page, adding a section, or fixing a bug
239
+ Fetch \`pulse://quickstart\` — workflow phases, spec skeleton, components, and theming in one fetch.
240
+
241
+ Quick checklist:
242
+ 1. Read the file you're changing
243
+ 2. Make the edit
244
+ 3. \`pulse_validate\` → \`/verify --quick\` (mid-iteration) or \`/verify\` (final check)
245
+
246
+ ### C — Targeted one-liner (label change, prop swap, CSS tweak)
247
+ No resource fetch needed. Read the file, make the change, run \`/verify --quick\` to check it looks right, then \`/verify\` when the user is happy.
248
+
249
+ ### D — Stuck mid-build
250
+ - Wrong component props? → \`pulse://guide/components\`
251
+ - Server data / store / auth? → \`pulse://guide/server\`
252
+ - CSS / theming? → \`pulse://guide/styles\`
253
+ - Routing or layout? → \`pulse://guide/routing\`
254
+ - Lighthouse < 100? → Fix the failing audit, re-run \`/verify\`
255
+ - Something looks wrong visually? → \`pulse_restart_server\` (hot-reloads specs instantly) then navigate the browser
256
+
257
+ ---
258
+
259
+ ## Essential rules — memorise these
260
+
261
+ - **\`meta.theme\` defaults to dark.** If the design is light, set \`theme: 'light'\` in the plan — not at screenshot time.
262
+ - **Never set \`hydrate\`** — the framework sets it automatically from the URL entry.
263
+ - **\`meta\` is always a plain object** — individual fields (\`title\`, \`description\`) can be async functions, but \`meta\` itself is \`{}\`.
264
+ - **After every file edit:** \`pulse_restart_server\` → navigate browser → check result. (Hot-reloads specs in ~200 ms — no process restart needed.)
265
+ - **\`/verify\` is always last** — it writes the \`.pulse-verified\` stamp. Without it, the stop hook blocks.
266
+
267
+ ---
268
+
269
+ ## Tools you'll use most
270
+
271
+ | Tool | When |
272
+ |---|---|
273
+ | \`pulse_intake\` | New project — capture name, pitch, palette, theme, vibe |
274
+ | \`pulse_sketch\` | New project — 3 layout directions before writing code |
275
+ | \`pulse_intent\` | Map a description to archetype + scaffold |
276
+ | \`pulse_suggest\` | **After first draft** — mid-build health check before hard validation |
277
+ | \`pulse_validate\` | After every write |
278
+ | \`pulse_review\` | Final gate after Lighthouse passes |
279
+ | \`pulse_restart_server\` | After every file edit |
280
+ | \`pulse_status\` | Session start — pages, server status, last build |
281
+ `,
282
+ }]
283
+ })
284
+ }
285
+ )
286
+
191
287
  // pulse://quickstart — combined essentials for simple/targeted tasks.
192
288
  // Reduces cold-start fetch round-trips from 4 (workflow + spec + components + styles)
193
289
  // to 1 for edits, bug fixes, and one-shot page builds that don't need the full guides.
@@ -222,9 +318,15 @@ server.registerResource(
222
318
 
223
319
  \`\`\`
224
320
  New page: pulse_intake → pulse_sketch → pulse_intent → build → validate → screenshot → design approval → /verify
225
- Edit/fix: read file → change → validate → /verify
321
+ Edit/fix: read file → change → validate → /verify --quick → (iterate) → /verify
226
322
  \`\`\`
227
323
 
324
+ **\`/verify\` has two modes:**
325
+ - \`/verify --quick\` — validate + screenshot + console + code review. No Lighthouse, no build. Use this during active iteration, between design rounds, or any time you're not yet at the "ready to ship" point. Fast (~10 s).
326
+ - \`/verify\` — full loop: Lighthouse desktop + mobile + perf trace + all of the above. Use once the user has approved the design and you're done building. Slow (~90 s).
327
+
328
+ **Rule: default to \`/verify --quick\` while building. Only run \`/verify\` (full) when the user signals they're done or happy with the result.**
329
+
228
330
  Tier 1 (static/read-only): phases 3 → 4 → 5a (design approval) → 5b (/verify)
229
331
  Tier 2 (interactive): phases 3 → 4 → 5a → 5b → 6 (tests) → 7 (review)
230
332
 
@@ -276,6 +378,43 @@ export default {
276
378
 
277
379
  ---
278
380
 
381
+ ## Event binding — input patterns
382
+
383
+ **The right pattern for each input type:**
384
+
385
+ | Input type | Pattern | Why |
386
+ |---|---|---|
387
+ | Button click | \`<button data-event="increment">\` | click fires mutation |
388
+ | Select / radio / checkbox | \`<select data-event="change:setFilter">\` | \`change\` fires on blur-with-changed-value — the correct commit semantic |
389
+ | Color picker | \`<input type="color" data-event="change:setColor">\` | same as select |
390
+ | Range slider | \`<input type="range" data-event="input:setVolume">\` | \`input\` fires on every drag step |
391
+ | Text / email / password / search | **No \`data-event\`** — read via FormData | \`data-event\` on text inputs re-renders on every keystroke, destroying cursor position and focus |
392
+
393
+ **Text input pattern (uncontrolled):**
394
+ \`\`\`js
395
+ // view — no data-event on the input
396
+ view: (state) => \`
397
+ <form data-action="submit">
398
+ <input type="text" name="email" placeholder="Email">
399
+ <button type="submit">Save</button>
400
+ </form>
401
+ \`,
402
+ // action — read FormData in onStart (before validation)
403
+ actions: {
404
+ submit: {
405
+ onStart: (state, formData) => ({ status: 'loading', email: formData.get('email') }),
406
+ validate: true,
407
+ run: async (state) => { /* use state.email */ },
408
+ onSuccess: (state) => ({ status: 'success' }),
409
+ onError: (state, err) => ({ status: 'error', error: err.message }),
410
+ }
411
+ }
412
+ \`\`\`
413
+
414
+ **\`change:\` is the blur/commit event** — it fires when a non-text input loses focus with a changed value. This is correct for selects, color pickers, checkboxes, and radios. It does NOT fire on every keystroke (use \`input:\` for live-updating sliders or search fields where re-render on every character is intentional and the element is not a free-text input).
415
+
416
+ ---
417
+
279
418
  ## Key components (quick reference)
280
419
 
281
420
  Import from \`@invisibleloop/pulse/ui\`. Always include \`/pulse-ui.css\` in \`meta.styles\`.
@@ -586,8 +725,11 @@ server.registerTool(
586
725
  return text(`File not found: ${file}`)
587
726
  }
588
727
  content = fs.readFileSync(file, 'utf8')
728
+ // Validate from the file's own directory so relative imports resolve
729
+ // correctly for pages in subdirectories
730
+ return validateContent(content, path.dirname(file))
589
731
  }
590
-
732
+
591
733
  return validateContent(content)
592
734
  }
593
735
  )
@@ -632,7 +774,9 @@ Rules for the spec you write:
632
774
  }
633
775
 
634
776
  const content = fs.readFileSync(fullPath, 'utf8')
635
- const validation = await validateContent(content)
777
+ // Validate from the file's own directory so relative imports resolve
778
+ // correctly for pages in subdirectories (src/pages/news/index.js)
779
+ const validation = await validateContent(content, path.dirname(fullPath))
636
780
  if (validation.content[0].text.startsWith('Invalid')) return validation
637
781
 
638
782
  const route = derivedRouteFromName(name)
@@ -788,6 +932,178 @@ ${onStart}${validate ? '\n validate: true,' : ''}
788
932
  }
789
933
  )
790
934
 
935
+ // ---------------------------------------------------------------------------
936
+ // pulse_create_tests
937
+ // ---------------------------------------------------------------------------
938
+
939
+ server.registerTool(
940
+ 'pulse_create_tests',
941
+ {
942
+ description: `Generate a starter test file for a Pulse page spec. Stubs out the formulaic test cases — null data, empty arrays, XSS injection, view landmarks, mutation logic, and onViewError — so you start with coverage scaffolding rather than a blank file.
943
+
944
+ Workflow:
945
+ 1. Call this with the spec file path after writing the spec
946
+ 2. Review the generated stub file — add real assertions where marked TODO
947
+ 3. Run the tests with pulse_run_tests
948
+ 4. Fill in any missing assertions until 100% branch coverage passes
949
+
950
+ The generated file uses @invisibleloop/pulse/testing (renderSync / render). Do NOT use raw html.includes() in tests.`,
951
+ inputSchema: {
952
+ file: z.string().describe('Absolute path to the spec file, e.g. /Users/me/project/src/pages/about.js'),
953
+ },
954
+ },
955
+ async ({ file }) => {
956
+ if (!fs.existsSync(file)) {
957
+ return text(`File not found: ${file}`)
958
+ }
959
+
960
+ const source = fs.readFileSync(file, 'utf8')
961
+ const relPath = path.relative(ROOT, file)
962
+ const testFile = file.replace(/\.js$/, '.test.js')
963
+
964
+ if (fs.existsSync(testFile)) {
965
+ return text(`Test file already exists: ${path.relative(ROOT, testFile)}\nDelete it first if you want to regenerate.`)
966
+ }
967
+
968
+ // Extract spec details to inform the scaffolding
969
+ const hasMutations = source.includes('mutations:')
970
+ const hasActions = source.includes('actions:')
971
+ const hasServer = source.includes('server:')
972
+ const hasOnViewError = source.includes('onViewError')
973
+ const hasValidation = source.includes('validation:')
974
+
975
+ const mutationNames = [...source.matchAll(/^\s{4}(\w+)\s*:\s*\(state/gm)].map(m => m[1])
976
+ const actionNames = hasActions
977
+ ? [...source.matchAll(/^\s{4}(\w+)\s*:\s*\{/gm)].map(m => m[1]).filter(n => n !== 'meta' && n !== 'server' && n !== 'mutations' && n !== 'actions' && n !== 'state' && n !== 'validation' && n !== 'constraints')
978
+ : []
979
+
980
+ const specName = path.basename(file, '.js')
981
+ const importPath = path.relative(path.dirname(testFile), file).replace(/\.js$/, '.js')
982
+
983
+ const lines = []
984
+ lines.push(`import { describe, it } from 'node:test'`)
985
+ lines.push(`import assert from 'node:assert/strict'`)
986
+ lines.push(`import { renderSync${hasServer ? ', render' : ''} } from '@invisibleloop/pulse/testing'`)
987
+ lines.push(`import spec from './${importPath.replace(/^\.\//, '')}'`)
988
+ lines.push(``)
989
+ lines.push(`// ${relPath}`)
990
+ lines.push(``)
991
+
992
+ lines.push(`describe('${specName} — view', () => {`)
993
+ lines.push(` it('renders main landmark', () => {`)
994
+ lines.push(` const r = renderSync(spec${hasServer ? `, { server: {} }` : ''})`)
995
+ lines.push(` assert(r.has('main#main-content'), 'missing <main id="main-content">')`)
996
+ lines.push(` })`)
997
+ lines.push(``)
998
+
999
+ if (hasServer) {
1000
+ lines.push(` it('handles null/empty server data without crashing', () => {`)
1001
+ lines.push(` // Replace null with realistic empty values for each server fetcher`)
1002
+ lines.push(` const r = renderSync(spec, { server: { /* TODO: add fetcher keys with null/[] values */ } })`)
1003
+ lines.push(` assert(r.has('main'))`)
1004
+ lines.push(` })`)
1005
+ lines.push(``)
1006
+
1007
+ lines.push(` it('handles empty array server data', () => {`)
1008
+ lines.push(` // Verify empty-state rendering (empty() component or fallback message)`)
1009
+ lines.push(` const r = renderSync(spec, { server: { /* TODO: fetchers with [] values */ } })`)
1010
+ lines.push(` assert(r.has('main'))`)
1011
+ lines.push(` })`)
1012
+ lines.push(``)
1013
+ }
1014
+
1015
+ lines.push(` it('does not render user input unescaped (XSS)', () => {`)
1016
+ lines.push(` // Replace one user-controlled field with a script tag`)
1017
+ lines.push(` const xss = '<script>alert(1)</script>'`)
1018
+ if (hasServer) {
1019
+ lines.push(` const r = renderSync(spec, { server: { /* TODO: pass xss as a string field */ } })`)
1020
+ } else {
1021
+ lines.push(` const r = renderSync(spec, { state: { /* TODO: pass xss as a string state field */ } })`)
1022
+ }
1023
+ lines.push(` assert(!r.text().includes('<script>'), 'XSS string rendered unescaped')`)
1024
+ lines.push(` })`)
1025
+ lines.push(``)
1026
+
1027
+ if (hasOnViewError) {
1028
+ lines.push(` it('onViewError returns fallback HTML when view throws', () => {`)
1029
+ lines.push(` assert(typeof spec.onViewError, 'function')`)
1030
+ lines.push(` const fallback = spec.onViewError(new Error('test'), spec.state ?? {}, {})`)
1031
+ lines.push(` assert(typeof fallback === 'string' && fallback.length > 0, 'onViewError must return non-empty HTML string')`)
1032
+ lines.push(` })`)
1033
+ lines.push(``)
1034
+ }
1035
+
1036
+ lines.push(`})`)
1037
+ lines.push(``)
1038
+
1039
+ if (hasMutations && mutationNames.length > 0) {
1040
+ lines.push(`describe('${specName} — mutations', () => {`)
1041
+ for (const name of mutationNames.slice(0, 6)) {
1042
+ lines.push(` it('${name} returns partial state', () => {`)
1043
+ lines.push(` assert(typeof spec.mutations.${name}, 'function')`)
1044
+ lines.push(` // TODO: call spec.mutations.${name}(spec.state, mockEvent) and assert the returned shape`)
1045
+ lines.push(` // e.g. const next = spec.mutations.${name}({ ...spec.state }, { target: { value: 'test' } })`)
1046
+ lines.push(` // assert.equal(typeof next, 'object')`)
1047
+ lines.push(` })`)
1048
+ lines.push(``)
1049
+ }
1050
+ lines.push(`})`)
1051
+ lines.push(``)
1052
+ }
1053
+
1054
+ if (hasActions && actionNames.length > 0) {
1055
+ lines.push(`describe('${specName} — actions', () => {`)
1056
+ for (const name of actionNames.slice(0, 4)) {
1057
+ lines.push(` it('${name}.onStart returns loading state', () => {`)
1058
+ lines.push(` if (!spec.actions?.${name}?.onStart) return`)
1059
+ lines.push(` const next = spec.actions.${name}.onStart(spec.state ?? {}, new FormData())`)
1060
+ lines.push(` assert(next.status === 'loading' || typeof next === 'object', 'onStart must return partial state')`)
1061
+ lines.push(` })`)
1062
+ lines.push(``)
1063
+ lines.push(` it('${name}.onError returns error state', () => {`)
1064
+ lines.push(` if (!spec.actions?.${name}?.onError) return`)
1065
+ lines.push(` const next = spec.actions.${name}.onError(spec.state ?? {}, new Error('test'))`)
1066
+ lines.push(` assert(typeof next === 'object', 'onError must return partial state')`)
1067
+ lines.push(` })`)
1068
+ lines.push(``)
1069
+ }
1070
+ lines.push(`})`)
1071
+ lines.push(``)
1072
+ }
1073
+
1074
+ if (hasValidation) {
1075
+ lines.push(`describe('${specName} — validation', () => {`)
1076
+ lines.push(` it('spec has validation rules', () => {`)
1077
+ lines.push(` assert(typeof spec.validation === 'object' && spec.validation !== null)`)
1078
+ lines.push(` assert(Object.keys(spec.validation).length > 0, 'validation object is empty')`)
1079
+ lines.push(` })`)
1080
+ lines.push(`})`)
1081
+ lines.push(``)
1082
+ }
1083
+
1084
+ const testContent = lines.join('\n')
1085
+ fs.writeFileSync(testFile, testContent, 'utf8')
1086
+
1087
+ const summary = [
1088
+ `Generated ${path.relative(ROOT, testFile)}`,
1089
+ ``,
1090
+ `Stubs created:`,
1091
+ ` ✓ View landmark test`,
1092
+ hasServer ? ` ✓ Null/empty server data tests` : null,
1093
+ ` ✓ XSS injection test`,
1094
+ hasOnViewError ? ` ✓ onViewError fallback test` : null,
1095
+ hasMutations && mutationNames.length > 0 ? ` ✓ Mutation tests (${mutationNames.slice(0, 6).join(', ')})` : null,
1096
+ hasActions && actionNames.length > 0 ? ` ✓ Action onStart/onError tests (${actionNames.slice(0, 4).join(', ')})` : null,
1097
+ hasValidation ? ` ✓ Validation structure test` : null,
1098
+ ``,
1099
+ `Next: search for "TODO" in the file — each TODO needs a real assertion.`,
1100
+ `Run: pulse_run_tests`,
1101
+ ].filter(Boolean).join('\n')
1102
+
1103
+ return text(summary)
1104
+ }
1105
+ )
1106
+
791
1107
  // ---------------------------------------------------------------------------
792
1108
  // pulse_fetch_page
793
1109
  // ---------------------------------------------------------------------------
@@ -820,7 +1136,7 @@ server.registerTool(
820
1136
  server.registerTool(
821
1137
  'pulse_restart_server',
822
1138
  {
823
- description: 'Stop and restart the Pulse dev server. Use after adding new pages or making changes that require a restart.',
1139
+ description: 'Reload specs in the running dev server. Tries a fast hot-reload first (no process restart); falls back to full kill/restart only if the server is not running.',
824
1140
  inputSchema: {},
825
1141
  },
826
1142
  async () => {
@@ -833,15 +1149,32 @@ server.registerTool(
833
1149
  } catch { /* use default */ }
834
1150
  }
835
1151
 
836
- // Kill any process on the port
1152
+ // Fast path — POST to the running dev server's hot-reload endpoint.
1153
+ // This reloads specs in-process and sends an SSE reload event to the browser
1154
+ // without killing the server process (~200 ms vs ~3 s for a full restart).
1155
+ const hotReloaded = await new Promise(resolve => {
1156
+ try {
1157
+ const req = http.request(
1158
+ { hostname: '127.0.0.1', port, path: '/_pulse/trigger-reload', method: 'POST', timeout: 2000 },
1159
+ res => resolve(res.statusCode === 204)
1160
+ )
1161
+ req.on('error', () => resolve(false))
1162
+ req.on('timeout', () => { req.destroy(); resolve(false) })
1163
+ req.end()
1164
+ } catch { resolve(false) }
1165
+ })
1166
+
1167
+ if (hotReloaded) {
1168
+ return text(`Dev server specs reloaded (hot) on port ${port}`)
1169
+ }
1170
+
1171
+ // Slow path — server not running or unresponsive, do a full kill/restart
837
1172
  try { execFileSync('sh', ['-c', `lsof -ti:${port} | xargs kill -9 2>/dev/null; true`]) } catch { /* nothing running */ }
838
1173
 
839
- // Start fresh dev server detached so it outlives the MCP tool call
840
1174
  const devScript = new URL('../cli/dev.js', import.meta.url).pathname
841
1175
  const proc = spawn(process.execPath, [devScript, '--root', ROOT], { detached: true, stdio: 'ignore' })
842
1176
  proc.unref()
843
1177
 
844
- // Wait until the server is actually accepting requests
845
1178
  const ready = await waitForServer(port)
846
1179
  return text(ready
847
1180
  ? `Dev server restarted on port ${port}`
@@ -908,13 +1241,18 @@ server.registerTool(
908
1241
  Reads the spec source, renders the view with initial state, runs all validation checks,
909
1242
  and returns a structured review brief. You must read everything carefully, find every
910
1243
  issue, and fix them all before reporting back to the user. Use this after completing
911
- any feature build.`,
1244
+ any feature build.
1245
+
1246
+ **Two modes:**
1247
+ - Default (full review): runs after Lighthouse passes — the canonical final gate. Returns full checklist + rendered HTML + validator output.
1248
+ - \`quick: true\`: lightweight mid-build check. Runs structural checks (main landmark, data-event on inputs, missing onError, hex in view, component patterns) without Lighthouse or the full checklist. Use after first draft, before the expensive verification pass. Not a substitute for the full review.`,
912
1249
  inputSchema: {
913
- file: z.string().optional().describe('Absolute path to the spec file to review'),
1250
+ file: z.string().optional().describe('Absolute path to the spec file to review'),
914
1251
  content: z.string().optional().describe('JavaScript spec content to review (alternative to file)'),
1252
+ quick: z.boolean().optional().describe('Run a lightweight mid-build check instead of the full review gate. Catches obvious structural issues without running Lighthouse. Use after first draft, before /verify.'),
915
1253
  },
916
1254
  },
917
- async ({ file, content }) => {
1255
+ async ({ file, content, quick = false }) => {
918
1256
  if (!file && !content) {
919
1257
  return text('Error: must provide either file or content')
920
1258
  }
@@ -924,10 +1262,119 @@ any feature build.`,
924
1262
 
925
1263
  let source = content
926
1264
  if (file) {
1265
+ if (!path.isAbsolute(file)) file = path.resolve(process.cwd(), file)
927
1266
  if (!fs.existsSync(file)) return text(`File not found: ${file}`)
928
1267
  source = fs.readFileSync(file, 'utf8')
929
1268
  }
930
1269
 
1270
+ // Quick mode — lightweight structural checks only, no full checklist or Lighthouse gate
1271
+ if (quick) {
1272
+ const issues = []
1273
+ const warnings = []
1274
+
1275
+ // Try to render the view
1276
+ let renderedHtml = ''
1277
+ if (file) {
1278
+ try {
1279
+ const mod = await import(`${file}?quick=${Date.now()}`)
1280
+ const spec = mod.default
1281
+ if (spec && typeof spec.view === 'function') {
1282
+ renderedHtml = spec.view(spec.state || {}, {})
1283
+ }
1284
+ } catch { /* ignore — server data dependency */ }
1285
+ }
1286
+
1287
+ if (renderedHtml) {
1288
+ if (!/<main[^>]*id=["']?main-content/.test(renderedHtml))
1289
+ issues.push('✗ Missing `<main id="main-content">` landmark')
1290
+ if (/<input[^>]*data-event/.test(renderedHtml))
1291
+ issues.push('✗ `data-event` on `<input>` — destroys focus on every keystroke, use FormData in onStart instead')
1292
+ if (/(className|htmlFor|onClick)=/.test(renderedHtml))
1293
+ issues.push('✗ React patterns found (className / htmlFor / onClick) — use class, for, data-event')
1294
+ if (/tabindex=["']?([1-9]\d*)/.test(renderedHtml))
1295
+ issues.push('✗ Positive tabindex found — remove, reorder DOM instead')
1296
+ const rawNavInSource = (source.match(/<nav[\s>]/gi) || []).length
1297
+ if (rawNavInSource > 0)
1298
+ issues.push(`✗ Raw <nav> tag in spec source — nav() renders <nav> internally; wrapping it in another <nav> creates duplicate landmarks that fail Lighthouse accessibility. Remove the outer <nav>.`)
1299
+ const viewBlockStripped = renderedHtml
1300
+ .replace(/href=["']#[^"']*["']/g, 'href="#"')
1301
+ .replace(/id=["'][^"']*["']/g, 'id=""')
1302
+ // Strip CSS custom property assignments from style attrs (--foo:#hex is a runtime
1303
+ // data binding, not an authored colour — e.g. style="--swatch-bg:#E8524A").
1304
+ // Standard property assignments like color:#000 are left intact and still flagged.
1305
+ .replace(/style="([^"]*)"/g, (_, v) => `style="${v.replace(/--[a-zA-Z][^:]*:[^;"]*/g, '')}"`)
1306
+ if (/#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped))
1307
+ warnings.push('⚠ Possible hex colour in rendered HTML — use var(--ui-*) tokens')
1308
+
1309
+ // Component pattern checks (unless creative override declared)
1310
+ if (!/component.free|creative\s+override/i.test(source)) {
1311
+ if (/<[^>]+class="[^"]*\bhero\b/.test(renderedHtml) && !source.includes('hero('))
1312
+ warnings.push('⚠ `.hero` class in HTML but no `hero()` component — use the component or declare creative override')
1313
+ if (/<[^>]+class="[^"]*\bcard\b/.test(renderedHtml) && !source.includes('card('))
1314
+ warnings.push('⚠ `.card` class in HTML but no `card()` component — use the component or declare creative override')
1315
+ }
1316
+ }
1317
+
1318
+ // Source-level checks (don't need rendered HTML).
1319
+ // Use line-anchored regex for actions: to avoid false positives from component
1320
+ // prop strings like hero({ actions: '...' }) or card({ actions: '...' }).
1321
+ const hasActionsBlock = /^\s{0,4}actions\s*:/m.test(source)
1322
+ if (hasActionsBlock && !source.includes('onError:'))
1323
+ issues.push('✗ Action missing `onError` — required, will throw at runtime')
1324
+ if (hasActionsBlock && !source.includes('onSuccess:'))
1325
+ issues.push('✗ Action missing `onSuccess` — required')
1326
+ if (source.includes('hydrate:'))
1327
+ issues.push('✗ `hydrate` is set manually — remove it, the framework sets it automatically')
1328
+ if (/meta\s*:\s*async/.test(source))
1329
+ issues.push('✗ `meta` is an async function — `meta` must be a plain object; make individual fields async instead')
1330
+
1331
+ // Structural confirmations — always shown regardless of issues, so the agent
1332
+ // gets positive signal on what's correct, not just a list of failures.
1333
+ const confirms = []
1334
+ if (renderedHtml) {
1335
+ confirms.push(/<main[^>]*id=["']?main-content/.test(renderedHtml) ? '✓ `<main id="main-content">` landmark present' : null)
1336
+ const rawNavInSource = (source.match(/<nav[\s>]/gi) || []).length
1337
+ if (rawNavInSource === 0) confirms.push('✓ No raw <nav> in spec source — nav() component handles landmarks correctly')
1338
+ const headings = [...renderedHtml.matchAll(/<h([1-6])[^>]*>/g)].map(m => parseInt(m[1]))
1339
+ if (headings.length > 0) {
1340
+ const orderOk = headings.every((h, i) => i === 0 || h <= headings[i - 1] + 1)
1341
+ confirms.push(orderOk ? `✓ Heading order correct (${headings.map(h => `h${h}`).join(' → ')})` : `⚠ Heading order may be skipping levels (${headings.map(h => `h${h}`).join(' → ')})`)
1342
+ }
1343
+ const interactiveWithoutLabel = [...renderedHtml.matchAll(/<button(?![^>]*aria-label)[^>]*>\s*<\/button>/g)]
1344
+ confirms.push(interactiveWithoutLabel.length === 0 ? '✓ No empty buttons without aria-label detected' : `⚠ ${interactiveWithoutLabel.length} button(s) appear empty — check aria-label`)
1345
+ const dataEvents = [...renderedHtml.matchAll(/data-event=/g)].length
1346
+ if (dataEvents > 0) confirms.push(`✓ ${dataEvents} data-event binding(s) found`)
1347
+ const creativeOverride = /component.free|creative\s+override/i.test(source)
1348
+ if (creativeOverride) confirms.push('✓ Creative override declared — component pattern checks are advisory')
1349
+ }
1350
+
1351
+ const lines = ['## Quick review\n']
1352
+
1353
+ if (confirms.filter(Boolean).length > 0) {
1354
+ lines.push('### Structural checks\n')
1355
+ for (const c of confirms.filter(Boolean)) lines.push(c)
1356
+ lines.push('')
1357
+ }
1358
+
1359
+ if (issues.length === 0 && warnings.length === 0) {
1360
+ lines.push('✓ No issues found. Run `pulse_validate` next, then `/verify`.')
1361
+ } else {
1362
+ if (issues.length) {
1363
+ lines.push('### Fix before proceeding\n')
1364
+ for (const i of issues) lines.push(i)
1365
+ lines.push('')
1366
+ }
1367
+ if (warnings.length) {
1368
+ lines.push('### Warnings (check these)\n')
1369
+ for (const w of warnings) lines.push(w)
1370
+ lines.push('')
1371
+ }
1372
+ lines.push('---\nFix issues, then run `pulse_validate` → `/verify --quick`.')
1373
+ }
1374
+
1375
+ return text(lines.join('\n'))
1376
+ }
1377
+
931
1378
  // Run the validator in a child process (same as pulse_validate)
932
1379
  let validationResult = '(could not run validator)'
933
1380
  if (file) {
@@ -1043,13 +1490,14 @@ ${(() => {
1043
1490
  }
1044
1491
 
1045
1492
  // Check spec source
1046
- // Hex colours in view — strip anchor hrefs and id attributes before checking so
1047
- // href="#fixtures" / id="main-content" / href="#join" are never flagged as hex colours.
1048
- // Only flag actual colour values: # followed by exactly 3 or 6 hex chars.
1493
+ // Hex colours in view — strip anchor hrefs, id attributes, template literal expressions,
1494
+ // and style attributes before checking so runtime-generated values (e.g. style="--swatch-bg:${hex}")
1495
+ // are never flagged. Only flag literal hex values authored directly in the view string.
1049
1496
  const viewBlock = source.slice(source.indexOf('view:'))
1050
1497
  const viewBlockStripped = viewBlock
1051
1498
  .replace(/href=["']#[^"']*["']/g, 'href="#"') // href="#anchor" → href="#"
1052
1499
  .replace(/id=["'][^"']*["']/g, 'id=""') // id="foo" → id=""
1500
+ .replace(/\$\{[^}]*\}/g, '${…}') // ${expr} — runtime values, not authored hex
1053
1501
  const hexInView = /#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b(?![0-9a-fA-F])/.test(viewBlockStripped)
1054
1502
  if (hexInView) {
1055
1503
  checks.push('⚠ **Possible hex colour in view** — use var(--ui-*) tokens only')
@@ -1210,13 +1658,16 @@ The stop hook compares each changed spec's mtime against this stamp. Any spec ne
1210
1658
  server.registerTool(
1211
1659
  'pulse_await_approval',
1212
1660
  {
1213
- description: `Pause the workflow to wait for the user's answer — call this immediately BEFORE asking a blocking question (e.g. the design-approval gate: "Happy with the layout and direction…?").
1661
+ description: `Pause the workflow gates for one turn-end — call this immediately BEFORE ending a turn that legitimately waits on the user. That covers:
1214
1662
 
1215
- It writes a .pulse-awaiting-approval marker that tells the Stop hooks (missing tests, coverage, verify stamp) to let the turn end without demanding /verify first. The marker is deleted automatically when the user sends their next message — the gates return in full force for your following turn.
1663
+ - **Blocking questions** — the design-approval gate ("Happy with the layout and direction…?"), or any decision only the user can make.
1664
+ - **Mid-flow progress pauses** — reporting between long verification steps (e.g. between Lighthouse runs while verifying many pages) when the work is not yet stamped.
1216
1665
 
1217
- **Always call this before the approval question, regardless of how you ask.** In some hosts (including Claude Code) even a dedicated question tool like AskUserQuestion ends the turn, which fires the Stop hooks. The marker is harmless if the turn does not end — it is simply consumed when the user replies. Calling it first works in every host; relying on the question tool to keep the turn open does not.
1666
+ It writes a .pulse-awaiting-approval marker that tells the Stop hooks (missing tests, coverage, verify stamp) to let the turn end without demanding /verify first. The marker is deleted automatically when the user sends their next message — the gates return in full force for your following turn, so re-call it before each pause in a long multi-page flow.
1218
1667
 
1219
- Do NOT use this to skip verification — it buys exactly one turn-end, paired with a question the user must answer. Calling it without asking anything just delays the gates by one message.`,
1668
+ **Always call this before an approval question, regardless of how you ask.** In some hosts (including Claude Code) even a dedicated question tool like AskUserQuestion ends the turn, which fires the Stop hooks. The marker is harmless if the turn does not end — it is simply consumed when the user replies.
1669
+
1670
+ Do NOT use this to skip verification — it buys exactly one turn-end, paired with something the user genuinely needs to see or answer. Calling it to dodge the gates just delays them by one message.`,
1220
1671
  inputSchema: {},
1221
1672
  },
1222
1673
  () => {
@@ -1793,6 +2244,15 @@ If your page genuinely doesn't fit a pattern, start from pulse://guide/spec and
1793
2244
  lines.push(`State: ${arch.stateHint}`)
1794
2245
  lines.push(`Server: ${arch.serverHint}`)
1795
2246
 
2247
+ lines.push(``)
2248
+ lines.push(`## ⚠ Before writing any code — ask these two questions`)
2249
+ lines.push(``)
2250
+ lines.push(`1. **Light or dark?** Pulse renders **dark** by default when \`meta.theme\` is unset. If the user hasn't stated a preference, ask now — discovering the wrong theme at the screenshot costs a full edit → restart → re-approval cycle.`)
2251
+ lines.push(` Add \`theme: 'light'\` to your plan or brief if confirmed light.`)
2252
+ lines.push(``)
2253
+ lines.push(`2. **Design inspiration?** Ask: "Do you have any design inspiration — a site you love, a screenshot, or a mood board image? Drop it into \`public/intake/\` or paste a URL." If yes, call \`pulse_extract_inspiration\` before proceeding.`)
2254
+ lines.push(` (Skip if this came from pulse_intake — it already asked.)`)
2255
+
1796
2256
  lines.push(``)
1797
2257
  lines.push(`## Guide sections to read`)
1798
2258
  lines.push(``)
@@ -2313,7 +2773,9 @@ The extracted values map directly to pulse_intake fields (palette, vibe, styleNo
2313
2773
  lines.push(`Navigate to: **${source}**`)
2314
2774
  lines.push('Observe the rendered page at desktop width (~1440px) and again at mobile (~375px).')
2315
2775
  lines.push('Take note of the above-the-fold area, then scroll through the full page.')
2316
- lines.push('**Fill in the sections below based on what you observe. Return a completed extraction, not an empty template.**\n')
2776
+ lines.push('**Fill in the sections below based on what you observe. Return a completed extraction, not an empty template.**')
2777
+ lines.push('')
2778
+ lines.push('> **If the URL is blocked or unreachable:** do not return a blank template. Instead, use your training knowledge of the site/brand to populate as many fields as you can, mark each cell with "(from knowledge, not live)" and note at the top that the URL was unreachable. A knowledge-based extraction is more useful than an empty template.\n')
2317
2779
  } else {
2318
2780
  lines.push('## Step 1 — Analyse the source (do this now)')
2319
2781
  lines.push(`Source: **${source}**`)
@@ -3380,7 +3842,7 @@ const PROP_ALIASES = [
3380
3842
  { component: 'input()', wrong: 'pattern', correct: 'attrs: { pattern }', note: 'HTML attributes go inside `attrs`: input({ attrs: { pattern: "[0-9]+" } }).' },
3381
3843
  ]
3382
3844
 
3383
- async function validateContent(content) {
3845
+ async function validateContent(content, tmpDir = PAGES_DIR) {
3384
3846
  // Source-level checks — run before the worker so errors are caught without importing
3385
3847
  const sourceWarnings = []
3386
3848
 
@@ -3388,9 +3850,17 @@ async function validateContent(content) {
3388
3850
  for (const { component, wrong, correct, note } of PROP_ALIASES) {
3389
3851
  // Match: nav({ ...wrong: or footer({ ...wrong: (within the call args)
3390
3852
  const fnName = component.replace('()', '')
3391
- const re = new RegExp(`${fnName}\\s*\\(\\s*\\{[^}]*\\b${wrong}\\s*:`, 'g')
3392
- if (re.test(content)) {
3853
+ const re = new RegExp(`${fnName}\\s*\\(\\s*\\{([^}]*)\\b${wrong}\\s*:`, 'g')
3854
+ let m
3855
+ while ((m = re.exec(content)) !== null) {
3856
+ // Inside attrs: { … } is the CORRECT placement for these HTML attributes —
3857
+ // when the text between the call's opening brace and the prop ends inside
3858
+ // an open attrs object, this is the recommended pattern, not a mistake.
3859
+ // (The naive match crossed into attrs blocks and flagged exactly what the
3860
+ // fix-it note tells people to write.)
3861
+ if (/attrs\s*:\s*\{[^}]*$/.test(m[1])) continue
3393
3862
  sourceWarnings.push(`"${wrong}" is not a recognised prop for ${component} — did you mean "${correct}"? ${note}`)
3863
+ break
3394
3864
  }
3395
3865
  }
3396
3866
 
@@ -3399,24 +3869,28 @@ async function validateContent(content) {
3399
3869
  const externalImgHosts = [
3400
3870
  {
3401
3871
  pattern: /https?:\/\/images\.unsplash\.com/,
3872
+ host: 'images.unsplash.com',
3402
3873
  entry: 'https://images.unsplash.com',
3403
3874
  cookieWarning: true,
3404
3875
  name: 'Unsplash',
3405
3876
  },
3406
3877
  {
3407
3878
  pattern: /https?:\/\/(?:fastly\.)?picsum\.photos/,
3879
+ host: 'picsum.photos',
3408
3880
  entry: 'https://picsum.photos https://fastly.picsum.photos',
3409
3881
  cookieWarning: false,
3410
3882
  name: 'picsum',
3411
3883
  },
3412
3884
  {
3413
3885
  pattern: /https?:\/\/res\.cloudinary\.com/,
3886
+ host: 'res.cloudinary.com',
3414
3887
  entry: 'https://res.cloudinary.com',
3415
3888
  cookieWarning: true,
3416
3889
  name: 'Cloudinary',
3417
3890
  },
3418
3891
  {
3419
3892
  pattern: /https?:\/\/cdn\.shopify\.com/,
3893
+ host: 'cdn.shopify.com',
3420
3894
  entry: 'https://cdn.shopify.com',
3421
3895
  cookieWarning: false,
3422
3896
  name: 'Shopify CDN',
@@ -3427,8 +3901,11 @@ async function validateContent(content) {
3427
3901
  if (fs.existsSync(configPath)) {
3428
3902
  try { configImgSrc = fs.readFileSync(configPath, 'utf8') } catch { /* ignore */ }
3429
3903
  }
3430
- for (const { pattern, entry, cookieWarning, name } of externalImgHosts) {
3431
- if (pattern.test(content) && !pattern.test(configImgSrc)) {
3904
+ for (const { pattern, host, entry, cookieWarning, name } of externalImgHosts) {
3905
+ // Config match is by HOST, not full URL — CSP sources are valid without a
3906
+ // scheme ('images.unsplash.com'), so requiring https?:// in the config
3907
+ // produced false positives for correctly configured projects.
3908
+ if (pattern.test(content) && !configImgSrc.includes(host)) {
3432
3909
  const cookieNote = cookieWarning
3433
3910
  ? `\n ⚠ ${name} sets tracking cookies that fail Lighthouse Best Practices regardless of CSP. For production, download images to public/images/ instead of linking to ${name} directly.`
3434
3911
  : ''
@@ -3440,9 +3917,12 @@ async function validateContent(content) {
3440
3917
  }
3441
3918
  }
3442
3919
 
3443
- // Write into PAGES_DIR so relative imports (e.g. '../components/nav.js') resolve correctly
3444
- fs.mkdirSync(PAGES_DIR, { recursive: true })
3445
- const tmpFile = path.join(PAGES_DIR, `.pulse-validate-${Date.now()}.mjs`)
3920
+ // Write the temp file into the directory the spec actually lives in (passed by
3921
+ // callers that know it) so relative imports resolve from the true location.
3922
+ // A page at src/pages/news/index.js importing '../../components/layout.js'
3923
+ // resolved wrongly when the temp file was always dropped in src/pages/ root.
3924
+ fs.mkdirSync(tmpDir, { recursive: true })
3925
+ const tmpFile = path.join(tmpDir, `.pulse-validate-${Date.now()}.mjs`)
3446
3926
  try {
3447
3927
  fs.writeFileSync(tmpFile, content, 'utf8')
3448
3928
 
@@ -3547,12 +4027,17 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
3547
4027
 
3548
4028
  ## Start here
3549
4029
 
3550
- **Fetch \`pulse://workflow\` before anything else.** It defines the exact sequence of phases and pass gates for every build task. Do not fetch guide sections or start writing code until you have read it. Skipping it means you will run steps in the wrong order.
4030
+ **Fetch \`pulse://start\` first.** It detects your context (new project, new page, edit, or bug fix) and returns exactly what you need — no upfront decision tree.
4031
+
4032
+ If you know your context already:
4033
+ - New page / new site → \`pulse://workflow\`
4034
+ - Edit, fix, or one-shot build → \`pulse://quickstart\`
3551
4035
 
3552
4036
  ## Guide resources
3553
4037
 
3554
4038
  | Resource | When to fetch |
3555
4039
  |---|---|
4040
+ | \`pulse://start\` | **Always fetch first** — context-aware entry point that routes you to the right resources |
3556
4041
  | \`pulse://quickstart\` | **Editing, bug-fixing, or a simple one-shot build** — workflow + spec skeleton + components + theming in one fetch. Skip the 4-resource cold-start for targeted tasks. |
3557
4042
  | \`pulse://workflow\` | **New project or new page** — full phase/gate sequence before writing any code. |
3558
4043
  | \`pulse://guide/spec\` | Building a spec — state, mutations, actions, streaming SSR, key rules, form layout |
@@ -3573,14 +4058,15 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
3573
4058
  - \`pulse_intake(name, pitch, features, targetUser?, palette?, font?, theme?, vibe?, styleNotes?, antiStyle?, inspiration?)\` — **Capture product details before scaffolding.** Run this first for any new project or branded template — before pulse_sketch or pulse_intent. **Gather answers by asking the user one free-form question at a time — never use multi-choice lists for open-ended intake questions.** The final intake question must always be: "Do you have any design inspiration — a site you love, a screenshot, or a mood board? Drop images into \`public/intake/\` or share a URL." If the user provides references, call \`pulse_extract_inspiration\` before proceeding. After intake, call pulse_sketch to explore structural directions before writing code.
3574
4059
  - \`pulse_sketch(brief, vibe?, antiStyle?, pageType?)\` — **Generate 3 structurally distinct layout directions before writing any code.** Call after pulse_intake. Returns three named directions (full-bleed, asymmetric split, typography-only, editorial flow, dense grid, story scroll, content-first) with wireframes, key decisions, and component strategies. Prevents defaulting to "centred hero + three columns" on every project. After choosing a direction, fetch \`pulse://guide/explore\` for raw HTML patterns.
3575
4060
  - \`pulse_intent(description)\` — Describe what you want to build in plain language and get back a matched archetype, component recommendations, a ready-to-adapt spec scaffold, and which guides to read. Use after pulse_intake and pulse_sketch, before fetching guides.
3576
- - \`pulse_suggest(content)\` — **Draft-mode feedback.** Paste a partial or complete spec and get constructive, non-blocking suggestions: missing pieces, likely omissions, component upgrades, empty-state reminders. A collaborator, not a gate. Use mid-build before running the hard validator.
4061
+ - \`pulse_suggest(content)\` — **Draft-mode feedback.** Paste a partial or complete spec and get constructive, non-blocking suggestions: missing pieces, likely omissions, component upgrades, empty-state reminders. A collaborator, not a gate. **Use after first draft** (after writing, before hard validation) — not just when something feels wrong.
4062
+ - \`pulse_create_tests(file)\` — **Generate a starter test file** for a spec. Stubs out the formulaic cases: null data, empty arrays, XSS injection, view landmarks, mutations, action onStart/onError, and onViewError. Search for "TODO" in the output and fill in real assertions. Covers 100% branch coverage requirements without hand-writing the boilerplate.
3577
4063
  - \`pulse_list_icons(filter?)\` — **List all available icon names grouped by category.** Always call this before importing icons — never guess a name or grep source files. Optional filter keyword narrows results (e.g. filter: "arrow").
3578
4064
  - \`pulse_check_contrast(css?, file?, theme?)\` — **Static WCAG contrast check.** Provide theme CSS content or a file path; it checks all token color pairings against WCAG AA thresholds (4.5:1 normal text, 3:1 large text/UI). Run immediately after writing a theme file — before pulse_build and Lighthouse. Catches palette mistakes in milliseconds.
3579
4065
  - \`pulse_tokens\` — **List all --ui-* CSS tokens** from the installed pulse-ui.css, grouped by category (colour, spacing, type-size, typography, shape). Call before writing theme overrides to avoid guessing names. Reminder: set \`--accent\` (no prefix) in theme.css — pulse-ui maps it to \`--ui-accent\` automatically.
3580
4066
  - \`pulse_status\` — **Project health snapshot.** Returns page count, routes, dev server status, last build age, and pulse-ui version check. Call at the start of a session to orient quickly without reading files.
3581
4067
  - \`pulse_list_structure\` — list pages, components, and pulse-ui version. Call at the start of every session.
3582
4068
  - \`pulse_validate\` — validate spec content. Call after every write. Fix all errors AND warnings.
3583
- - \`pulse_review\` — switch into reviewer mode and critically examine a spec you just built. Returns the source, rendered HTML, validator output, and a full review checklist. **Call this only after validate, Lighthouse (desktop + mobile), and tests all pass — it is the final phase before declaring done.**
4069
+ - \`pulse_review(file, { quick? })\` — **Two modes.** Default: full review gate (call after Lighthouse + tests pass). Pass \`quick: true\` for a lightweight mid-build structural check — no Lighthouse, just catches obvious issues (missing main landmark, data-event on inputs, missing onError, hex colours, component pattern violations). Use quick mid-build after \`pulse_suggest\`. **Full mode only after validate, Lighthouse (desktop + mobile), and tests all pass.**
3584
4070
  - \`pulse_create_page\` — validate a page spec you already wrote to disk. **Always write the file with the Write tool first, then call this.** Never pass content to this tool.
3585
4071
  - \`pulse_create_component\` — register a component you wrote with the Write tool. Write the file first, then call this.
3586
4072
  - \`pulse_create_store\` — register a pulse.store.js you wrote with the Write tool. Write the file first, then call this.
@@ -3588,7 +4074,7 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
3588
4074
  - \`pulse_run_tests\` — run the project test suite (npm test). Use after writing or editing specs.
3589
4075
  - \`pulse_stamp\` — write the \`.pulse-verified\` stamp. Call as the **last step** of \`/verify\`, after Lighthouse and \`pulse_review\` both pass. Using this MCP tool avoids the mtime race that \`date +%s > .pulse-verified\` can cause when the stamp and the last spec write land in the same filesystem second.
3590
4076
  - \`pulse_fetch_page(url)\` — HTTP GET the dev server URL. Use to verify SSR output.
3591
- - \`pulse_restart_server\` — stop and restart the dev server.
4077
+ - \`pulse_restart_server\` — hot-reload specs in the running dev server (~200 ms). Falls back to full kill/restart if the server is not running.
3592
4078
  - \`pulse_build\` — production build + starts prod server on devPort+1 for Lighthouse. Returns the URL. Call \`pulse_restart_server\` after to return to dev. **Slow — takes 30–60 s. Tell the user before calling.**
3593
4079
  - \`pulse_check_version\` — check installed package version, static asset version, and latest on npm. Use this instead of running npm commands when the user asks about updates.
3594
4080
  - \`pulse_update\` — install the latest \`@invisibleloop/pulse\` package and re-copy \`pulse-ui.css\`, \`pulse-ui.js\`, and the agent checklist into \`public/\`. One command does the full upgrade.
@@ -3604,9 +4090,13 @@ const PULSE_GUIDE_INDEX = `# Pulse Framework Guide
3604
4090
 
3605
4091
  ## MANDATORY: Verify every build
3606
4092
 
3607
- **After writing or editing any page spec, you MUST run \`/verify\` before declaring done.**
4093
+ **After writing or editing any page spec, you MUST run \`/verify\` or \`/verify --quick\` before declaring done.**
4094
+
4095
+ **Choose the right mode:**
4096
+ - \`/verify --quick\` — use during active building and iteration. Runs: validate → screenshot → console check → code review → stamp. No Lighthouse, no production build. Fast (~10 s). Use this between every round of changes.
4097
+ - \`/verify\` (full) — use when the user signals they're done or happy with the result. Runs everything: validate → screenshot → production build → Lighthouse desktop → Lighthouse mobile → performance trace → console check → code review → stamp. Slow (~90 s). Run this once at the end.
3608
4098
 
3609
- \`/verify\` is the single canonical verification workflow. It runs: validate → screenshot → console check → network check → SSR check → production build → Lighthouse desktop → Lighthouse mobile → performance trace → code review → writes the \`.pulse-verified\` stamp. The stop hook checks this stamp — if it is missing or older than the last spec edit, the hook will block and make you run verification again.
4099
+ The stop hook checks the \`.pulse-verified\` stamp — \`/verify --quick\` writes the stamp too, so it satisfies the hook for mid-build turns. The full \`/verify\` is required before the final commit.
3610
4100
 
3611
4101
  **Do not replicate \`/verify\` steps manually.** Running the individual tools yourself does not write the stamp. The agent will be blocked at the end regardless.
3612
4102