mockpit 1.3.0 → 3.0.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 (77) hide show
  1. package/README.md +66 -202
  2. package/bin/demoData.js +5 -5
  3. package/bin/initDesign.js +6 -6
  4. package/bin/mockpit.js +687 -192
  5. package/bin/poster.js +377 -0
  6. package/dist/mcp/server.js +110 -19
  7. package/dist/server/answerAdditions.d.ts +7 -0
  8. package/dist/server/answerAdditions.js +76 -0
  9. package/dist/server/apiViews.d.ts +563 -41
  10. package/dist/server/apiViews.js +223 -74
  11. package/dist/server/app.d.ts +10 -2
  12. package/dist/server/app.js +3284 -782
  13. package/dist/server/appIcon.js +15 -5
  14. package/dist/server/appIconPng.js +3 -3
  15. package/dist/server/askNudges.js +1 -1
  16. package/dist/server/cdnInline.d.ts +8 -0
  17. package/dist/server/cdnInline.js +638 -0
  18. package/dist/server/codeWorker.js +2 -1
  19. package/dist/server/decisionList.d.ts +45 -0
  20. package/dist/server/decisionList.js +302 -0
  21. package/dist/server/decisions.d.ts +8 -8
  22. package/dist/server/decisions.js +18 -17
  23. package/dist/server/designGuide.d.ts +10 -6
  24. package/dist/server/designGuide.js +173 -78
  25. package/dist/server/embeds.d.ts +4 -1
  26. package/dist/server/embeds.js +57 -6
  27. package/dist/server/eventLog.d.ts +2 -2
  28. package/dist/server/eventLog.js +11 -11
  29. package/dist/server/events.d.ts +9 -6
  30. package/dist/server/feedbackBatch.d.ts +38 -16
  31. package/dist/server/feedbackBatch.js +121 -50
  32. package/dist/server/fills.d.ts +4 -0
  33. package/dist/server/fills.js +55 -1
  34. package/dist/server/guidance.d.ts +23 -0
  35. package/dist/server/guidance.js +71 -0
  36. package/dist/server/handoff.d.ts +4 -1
  37. package/dist/server/handoff.js +8 -2
  38. package/dist/server/icons.js +1 -1
  39. package/dist/server/index.js +49 -11
  40. package/dist/server/jump.d.ts +21 -0
  41. package/dist/server/jump.js +175 -0
  42. package/dist/server/knobs.d.ts +3 -3
  43. package/dist/server/knobs.js +6 -6
  44. package/dist/server/mcpHttp.d.ts +8 -1
  45. package/dist/server/mcpHttp.js +44 -7
  46. package/dist/server/mcpSpec.d.ts +2 -1
  47. package/dist/server/mcpSpec.js +172 -50
  48. package/dist/server/parts.js +9 -1
  49. package/dist/server/postSurfaces.js +3 -0
  50. package/dist/server/run.d.ts +1 -1
  51. package/dist/server/run.js +31 -17
  52. package/dist/server/runApi.d.ts +3 -3
  53. package/dist/server/runApi.js +34 -24
  54. package/dist/server/sqlStore.d.ts +117 -94
  55. package/dist/server/sqlStore.js +1089 -141
  56. package/dist/server/standings.d.ts +25 -0
  57. package/dist/server/standings.js +113 -0
  58. package/dist/server/surfacePage.d.ts +6 -9
  59. package/dist/server/surfacePage.js +361 -86
  60. package/dist/server/types.d.ts +252 -69
  61. package/dist/server/types.js +87 -69
  62. package/extensions/mockpit.js +245 -65
  63. package/guide/AGENT_SETUP.md +13 -1
  64. package/guide/packs/design-craft.md +34 -0
  65. package/guide/topics/asks.md +117 -69
  66. package/guide/topics/feedback.md +156 -119
  67. package/guide/topics/html.md +19 -32
  68. package/guide/topics/http.md +43 -40
  69. package/guide/topics/knobs.md +7 -10
  70. package/guide/topics/scripts.md +24 -35
  71. package/guide/topics/surfaces.md +50 -41
  72. package/opencode/mockpit.js +120 -0
  73. package/opencode/package.json +8 -0
  74. package/package.json +4 -6
  75. package/skills/launch-video/SKILL.md +12 -12
  76. package/skills/mockpit/SKILL.md +35 -23
  77. package/viewer/dist/index.html +9 -8
package/bin/mockpit.js CHANGED
@@ -9,10 +9,11 @@ import {
9
9
  writeFileSync,
10
10
  writeSync,
11
11
  } from "node:fs";
12
- import { tmpdir, userInfo } from "node:os";
12
+ import { homedir, tmpdir, userInfo } from "node:os";
13
13
  import { dirname, join } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { parseArgs } from "node:util";
16
+ import { findBrowser, queuePoster } from "./poster.js";
16
17
  import { serveUrl } from "./serveUrl.js";
17
18
 
18
19
  const BASE = (process.env.MOCKPIT_URL ?? "http://localhost:8228").replace(/\/$/, "");
@@ -28,79 +29,103 @@ const HEADERS = {
28
29
 
29
30
  const HELP = `mockpit — a live visual surface for terminal coding agents
30
31
 
31
- vocabulary: project › mock › state › variant › version. A project is a repo; a
32
- mock is the page or component on stage; a state is one moment of it ("Lab
33
- open"); a variant is a parallel design of a state; a version is its history.
34
- Mark the parts you want feedback on with data-part="name" in the html.
32
+ vocabulary: project › design › state › variant › version. A project is a repo; a
33
+ design is an idea, screen or component, by slug; a state is one thing the user sees in it ("Lab
34
+ open"); a variant is a parallel take on a state; a version is its history.
35
+ Declare the parts you want feedback on with data-part="name" in the html.
35
36
 
36
37
  the loop (never blocks on the user):
37
38
  1. mockpit init once per repo; read the brief (mockpit guide).
38
- 2. mockpit publish once per variant; mark parts with data-part.
39
- 3. A choice is several variants plus one ask that binds them: mockpit ask
39
+ 2. mockpit publish once per variant; declare parts with data-part.
40
+ 3. A choice is several variants plus one question that binds them: mockpit ask
40
41
  with options bound to variants. A variant decides everything it differs
41
42
  on; don't re-ask it. Visual options carry a variant or knob set. One
42
43
  render plus a control: --knobs.
43
44
  4. Tell the user in one line where to look, then end your turn. The question
44
- itself lives in the mock (ask), never in chat. The user answers in the
45
+ itself lives in the design, never in chat. The user answers in the
45
46
  browser at their own pace. Never poll.
46
47
  5. When the user says they answered (or a mockpit watch line wakes you), run
47
48
  mockpit feedback. Every write also returns feedback; read it.
48
- 6. mockpit publish the revision; mockpit export once a variant is accepted.
49
+ 6. mockpit publish the revision; mockpit export once a variant is in use.
49
50
  In Claude Code, arm mockpit watch under Monitor after asking, then end the
50
- turn; elsewhere the user's next message is the wake-up.
51
+ turn, unless you have the mockpit plugin, which wakes you itself; elsewhere
52
+ the user's next message is the wake-up.
51
53
 
52
54
  verbs:
53
55
  mockpit init [--project name] [--kit <id>] [--kit-url <url> --kit-doc <file|text>]
54
56
  detect the repo's design system, store
55
57
  palette/kit/icon sets, write .mockpit/starter.html
56
- mockpit publish --mock <slug> --html <file> [options]
58
+ mockpit publish --design <slug> --html <file> [options]
57
59
  create or version one variant of one state
58
- --state <label> state, in the user's words (omit for a single-state mock)
60
+ --state <label> state, in the user's words (omit for a single-state design)
59
61
  --variant <name> variant label (default "default")
60
62
  --html <file|-> the html body (or a positional file)
61
63
  --surfaces <json|file> instead: the full ordered surface list; {"id"}
62
64
  alone keeps a surface, a missing id removes it
63
65
  --parts <name=file|-> instead: replace just that data-part's element in
64
66
  the latest version (repeatable; name#key targets one instance)
65
- --fills <part#fill=file|-> instead: replace or add one fill of a slot in
67
+ --fills <part#fill=file|-> instead: replace or add one fill of a part in
66
68
  the latest version (repeatable)
67
- --title <t> mock title
68
- --kind <k> component|page (default component)
69
+ --title <t> design title
70
+ --kind <k> screen|component, only for an app's screens (default idea);
71
+ with no html it plans one, drawn by a later publish
72
+ --area <name> where in the product it belongs ("Shell"); Home groups by it
73
+ --source <path> the code path that implements it
69
74
  --knobs <json|file> knobs in tunekit usePane shape, keyed by path
70
75
  --from <N> branch from version N
76
+ --amend replace the latest version in place: a fix before the
77
+ user has acted on it (one request, one version)
78
+ --withdraw take the latest version back, under the same rule
71
79
  --prompt <text> what prompted this version
72
- --note <text> one line on what this variant is, shown beside it
80
+ --description <text> one line on what this variant is, shown beside it
73
81
  --widths <list> device widths to show it at, e.g. 390,1280
74
- --flow <name> [--step <N>] make this screen step N of a flow (0 takes
75
- it out); with no html it only places the screen
82
+ --flow <name> [--step <N>] make this state step N of a flow (0 takes
83
+ it out); with no html it only places the state
76
84
  --project <name> project (default: git remote, else directory name)
77
85
  --kit <id> opt the html surface into a kit (repeatable; see "mockpit kits")
86
+ --poster <file> a still of the page (png, jpg); Studio and Home show it
87
+ instead of running the page. Send one with every version
88
+ that changes the page, or it goes
78
89
  --md/--mermaid/--diff/--terminal/--data/--code/--image <file|->
79
90
  add a surface of that kind after the html (repeatable;
80
91
  --data is a JSON tree)
81
- mockpit ask --mock <slug> "<question>" --option <label[=variant]> ...
92
+ mockpit ask --design <slug> "<question>" --option <label[=variant]> ...
82
93
  ask the user; bind options to variants
83
94
  --option <l[=v]> an option (repeatable); "=variant" binds it to a variant,
84
95
  "=variant@3" to its version 3, "=knob:path=value" sets one knob
85
- --scope <s> mock|state|part (default mock)
86
- --state <label> the state a state-scoped ask is about
87
- --part <name> the part a part-scoped ask is about
96
+ --recommend <l> the option you would pick, by label; a quiet mark on it
97
+ --scope <s> design|state|part (default design)
98
+ --state <label> the state a state-scoped question is about
99
+ --states <label> a design-wide question: a state its answers can be seen
100
+ on (repeatable, best first)
101
+ --part <name> the part a part-scoped question is about
88
102
  --multi allow several answers
89
- --id <id> stable id; reusing one replaces that ask
90
- --asks <json|file> several asks at once, full shape
91
- mockpit read [slug] [--body] [--history]
92
- no slug: every mock plus what the user
93
- is doing now; a slug: that mock
103
+ --id <id> stable id; reusing one replaces that question
104
+ --asks <json|file> several questions at once, full shape
105
+ mockpit read [slug] [--body] [--history] [--decisions]
106
+ what the user already decided, then
107
+ no slug: every design plus what the user
108
+ is doing now; a slug: that design
94
109
  mockpit feedback what the user sent since you last heard,
95
110
  plus pending; returns at once
96
- mockpit say "<text>" --mock <slug> [--state s] [--variant v]
97
- a plain-text message in a mock's thread
98
- mockpit export --mock <slug> [--state s] [--variant v] [--out <dir>]
99
- write the accepted html + history per state
100
- to .mockpit/accepted/<mock>/<state>/
111
+ mockpit say "<text>" --design <slug> [--state s] [--variant v]
112
+ a plain-text message in a design's thread
113
+ mockpit move --design <slug> --variant <v>... (--to <design> [--to-state s] | --archive | --restore)
114
+ move variants elsewhere, archive or restore them
115
+ mockpit edit --design <slug> [--slug s] [--title t] [--kind k] [--area a] [--source p]
116
+ change a design without a new version ("" clears)
117
+ mockpit edit --project <name> --rename <new>
118
+ rename a project
119
+ mockpit edit --project <name> --area-order "Shell,Food"
120
+ the order Home lists areas in
121
+ mockpit export --design <slug> [--state s] [--variant v] [--out <dir>]
122
+ write the html in use + history per state
123
+ to .mockpit/accepted/<design>/<state>/
101
124
  mockpit upload <file> [--kind image|file]
102
125
  upload an asset, print its id and URL
103
126
  mockpit guide [--topic <id>] the project-aware brief, or one reference topic
127
+ mockpit rules [--mine] send .mockpit/rules.md (or, with --mine, your
128
+ ~/.mockpit/rules.md) to the server for the brief
104
129
  mockpit run <file|-> run a script against the mockpit API on the
105
130
  server (mockpit guide --topic scripts)
106
131
  mockpit watch [--session <id>] stream user feedback, one line per Send
@@ -109,7 +134,7 @@ verbs:
109
134
  other commands:
110
135
  mockpit serve [--port N] [--host H] [--open]
111
136
  start the server (API + viewer)
112
- mockpit demo seed the Writer mock to explore the viewer
137
+ mockpit demo seed the Writer design to explore the viewer
113
138
  mockpit icons [add|remove <set>...] list the icon sets html surfaces can name
114
139
  (icon="prefix:name"); add or remove an
115
140
  Iconify set for this project
@@ -132,6 +157,7 @@ environment:
132
157
  MOCKPIT_PROJECT project name; overrides the git-remote/directory default
133
158
  MOCKPIT_URL server base URL (default http://localhost:8228)
134
159
  MOCKPIT_TOKEN bearer token for a deployed instance
160
+ CHROME_PATH the browser for automatic posters; pointing nowhere turns them off
135
161
  MOCKPIT_HOST address serve binds to (default: every interface)
136
162
  MOCKPIT_LOG serve's event log file, or "off" (default ~/.mockpit/events.jsonl)
137
163
  MOCKPIT_SESSION fixed session id (overrides auto-detection)
@@ -148,6 +174,15 @@ const COMMAND_HELP = {
148
174
  (gitignored). --kit makes a bundled or project kit the default for every html
149
175
  surface. --kit-url defines the project's own kit (id from --kit, else
150
176
  "project") and makes it the default; the doc is the cheat sheet the brief prints.`,
177
+ rules: `mockpit rules [--project <name>]
178
+ mockpit rules --mine
179
+ Guidance on top of the core brief, highest first: the project's
180
+ .mockpit/rules.md (committed with the repo), your ~/.mockpit/rules.md, then
181
+ the packs either names (\`packs: [design-craft]\` in its frontmatter). A file
182
+ has "## Rules" (always followed) and "## Suggestions" (aimed for).
183
+ This sends the file as it is on disk; a missing file clears the stored copy.
184
+ init sends the project's too. A local server reads yours live, so --mine is
185
+ for a deployed one.`,
151
186
  kit: `mockpit kit add <id> --url <https url> --doc <file.md|text> [--script <url>]
152
187
  mockpit kit remove <id>
153
188
  A project kit is a stylesheet (and optional script) on the CDN allowlist plus a
@@ -159,12 +194,21 @@ mockpit icons remove <set> [<set>...]
159
194
  html surfaces write <i icon="lucide:check"></i>; the server inlines the svg.
160
195
  lucide and mage are bundled. add finds @iconify-json/<set> in node_modules,
161
196
  the global npm root or bun's cache, else fetches it from jsDelivr.`,
162
- publish: `mockpit publish --mock <slug> --html <file> [options]
163
- mockpit publish --mock <slug> --surfaces <json|file> [options]
164
- mockpit publish --mock <slug> --parts <name=file|-> ... [options]
165
- mockpit publish --mock <slug> --fills <part#fill=file|-> ... [options]
166
- Creates the mock, state or variant, or the next version of an existing one.
167
- --state <label> state in the user's words (omit for a single-state mock)
197
+ publish: `mockpit publish --design <slug> --html <file> [options]
198
+ mockpit publish --design <slug> --surfaces <json|file> [options]
199
+ mockpit publish --design <slug> --parts <name=file|-> ... [options]
200
+ mockpit publish --design <slug> --fills <part#fill=file|-> ... [options]
201
+ mockpit publish --design <slug> [--state s] [--variant v] --withdraw
202
+ Creates the design, state or variant, or the next version of an existing one.
203
+ One request gets one version: fixes to it before the user acts use --amend.
204
+ --amend replace the variant's latest version in place (same number)
205
+ instead of adding one. Only from the session that published
206
+ it, and only while the user hasn't acted on it (no Send,
207
+ comment, draft answer, not in use)
208
+ --withdraw take the variant's latest version back under the same
209
+ rule: the version before it is current again, and a
210
+ variant with no other version goes
211
+ --state <label> state in the user's words (omit for a single-state design)
168
212
  --variant <name> variant label (default "default")
169
213
  --surfaces <json|file> the full ordered list: {"id"} alone keeps a surface,
170
214
  a missing id removes it, order is the list order
@@ -172,68 +216,112 @@ mockpit publish --mock <slug> --fills <part#fill=file|-> ... [options]
172
216
  (name#key for one instance) in the latest version; repeatable
173
217
  --fills <part#fill=file|-> replace the element carrying data-part="part"
174
218
  data-fill="fill" in the latest version, or add it after
175
- that slot's other fills; the marks are added if missing
176
- --title <t> mock title
177
- --kind <k> component|page (default component)
219
+ that part's other fills; the marks are added if missing
220
+ --title <t> design title
221
+ --kind <k> screen|component|idea: screen for a screen of the product,
222
+ component for a reusable design screens embed (embedding makes one);
223
+ omit for ideas, diagrams, reviews (default idea)
224
+ --area <name> where in the product it belongs, in the project's words
225
+ ("Shell", "Food"); Home groups each tab by it. "" clears
226
+ --source <path> the code path that implements it (apps/web/src/shell). "" clears
178
227
  --knobs <json|file> knobs in tunekit usePane shape
179
228
  --from <N> branch from version N
180
229
  --fork <v[@N]> start this new variant from sibling v (its version N, else
181
230
  its latest); alone it copies v, with --parts/--fills it
182
231
  edits the copy
183
232
  --prompt <text> what prompted this version
184
- --note <text> one line on what this variant is, shown beside it (never
233
+ --description <text> one line on what this variant is, shown beside it (never
185
234
  inside the design)
186
- --widths <list> device widths the mock is judged at, side by side, e.g.
235
+ --widths <list> device widths the design is judged at, side by side, e.g.
187
236
  390,1280 (the ones the user ships to)
188
- --flow <name> a flow this screen is a step of: the screens the user moves
189
- between, in order, across mocks; without html it only
190
- places the existing screen
237
+ --flow <name> a flow this state is a step of: the states the user moves
238
+ between, in order, across designs; without html it only
239
+ places the existing state
191
240
  --step <N> its 1-based place in the flow (moves it if it is there);
192
241
  0 takes it out; omitted, it joins at the end
193
242
  --project <name> project (default: git remote, else directory name)
243
+ --poster <file> a still of the page (png, jpg) that Studio and Home show; without
244
+ one, a local Chrome or Chromium takes it just after the publish
245
+ instead of running it; with --parts or --fills it is the
246
+ still of the spliced page. A version that changes the page
247
+ without one drops the old still
194
248
  --md/--mermaid/--diff/--terminal/--data/--code/--image <file> more surfaces
195
249
  --json / --quiet
196
250
  Prints the parts found per state, parts that vanished or were renamed,
197
- nudges, a ready-to-run "mockpit ask" when variants have no ask, and feedback.`,
198
- ask: `mockpit ask --mock <slug> "<question>" --option <label[=variant]> ... [--scope mock|state|part]
199
- [--state s] [--part p] [--multi] [--id id] [--after <ask id>]
200
- mockpit ask --mock <slug> --asks <json|file>
201
- Ask the user. A choice is several variants plus one ask that binds them.
251
+ nudges, a ready-to-run "mockpit ask" when variants have no question, and feedback.`,
252
+ ask: `mockpit ask --design <slug> "<question>" --option <label[=variant]> ... [--scope design|state|part]
253
+ [--state s] [--states s]... [--part p] [--multi] [--id id] [--after <ask id>] [--recommend <label>]
254
+ mockpit ask --design <slug> --asks <json|file>
255
+ Ask the user. A choice is several variants plus one question that binds them.
202
256
  Then tell the user where to look and end your turn.`,
203
- read: `mockpit read [slug] [--project <name>] [--body] [--history] [--tree]
204
- No slug: one line per mock (states, variants, open asks), one per flow, plus pending, what
205
- the user is doing now. A slug: the mock's states, variants, asks with
206
- answers, parts, knobs and settled values, plus its pending, as JSON.
257
+ read: `mockpit read [slug] [--project <name>] [--body] [--history] [--tree] [--decisions]
258
+ Read this first. Both forms carry the project's decision list, what the user
259
+ already settled, from their replies: treat each line as settled unless they say
260
+ otherwise. No slug: one line per design (states, variants, open questions), one per
261
+ flow, plus pending, what the user is doing now, then the decisions. A slug: the
262
+ design's states, variants, questions with answers, parts, knobs and settled values,
263
+ plus its pending and decisionList (its own lines marked here), as JSON.
207
264
  --body includes surfaces, --history version rows. --tree (no slug): the
208
- project as a tree, each mock's variants with status and forkedFrom, and the
209
- mocks it uses and is used in, as JSON. Never consumes feedback.`,
265
+ project as a tree, each design's variants with status and forkedFrom, and the
266
+ designs it embeds and is embedded in, as JSON. --decisions: the whole list, when
267
+ a read says lines were left out. Never consumes feedback.`,
210
268
  feedback: `mockpit feedback [--project <name>] [--session <id>]
211
- Returns at once with {feedback, pending} as JSON: one batch per mock the user
212
- sent since you last heard ({mock, reply: {asks, mix, tuned, comments},
213
- comments, accepted, archived, dropped}, or {mock, resolved: true, dismissed} when the
214
- user closed it unanswered), delivered once across feedback, watch and
215
- every write; and pending ({mock, viewerOpen, draft}) so you can tell the
269
+ Returns at once with {feedback, pending} as JSON: one batch per design the user
270
+ sent since you last heard ({design, reply: {asks, borrowed, tuned, comments,
271
+ verdict}, messages, inUse, archived}), delivered once across feedback, watch and
272
+ every write; and pending ({design, viewerOpen, draft}) so you can tell the
216
273
  user "take your time" or "2 of 3 answered". Run it when the user says they
217
274
  answered.`,
218
- say: `mockpit say "<text>" --mock <slug> [--state s] [--variant v]
219
- A plain-text message in the mock's thread, as your session's agent.`,
275
+ say: `mockpit say "<text>" --design <slug> [--state s] [--variant v]
276
+ A plain-text message in the design's thread, as your session's agent.`,
277
+ move: `mockpit move --design <slug> [--state s] --variant <v> [--variant <v>...]
278
+ --to <design> [--to-state <s>] | --to-state <s> | --archive | --restore
279
+ mockpit move --design <slug> [--state s] [--variant <v>...] --hide-older | --delete-older
280
+ Tidy up. --to or --to-state moves them to another design or tab (a new state is
281
+ created) and takes them out of here; --archive takes them out of sight; --restore
282
+ brings them back. Their group goes along; one in use stays and is reported. The
283
+ thread says what happened, so the user can undo it.
284
+ Only when the user asks: --hide-older hides all but each variant's newest visible
285
+ version (no --variant: every variant), and --delete-older deletes them for good,
286
+ skipping and listing any that a reply, comment, question, embed, copy or fork still
287
+ points at, or that is in use. It can't be undone: confirm first.`,
288
+ edit: `mockpit edit --design <slug> [--slug <new>] [--title <t>] [--kind screen|component|idea]
289
+ [--first-state <name>] [--notes <text>|""]
290
+ [--area <name>|""] [--source <path>|""] [--project <name>]
291
+ mockpit edit --project <name> --rename <new name>
292
+ mockpit edit --project <name> --area-order "Shell,Food,Chat"
293
+ Change a design without publishing a new version; "" clears area or source. A new
294
+ slug rewrites every embed, data-goto and flow step that names the old one, in
295
+ every version. A design still embedded somewhere stays a component, not an idea.
296
+ --notes drafts the design's short plain-text spec, which read returns; the user's own
297
+ edit of it stands, so the draft is then not applied and the result says so.
298
+ --first-state names a design's one unnamed state, and every variant it holds goes
299
+ there, so the design can then have more states.
300
+ --rename moves the project's designs, sessions, flows and design settings to the
301
+ new name (refused if that project has designs); pass --project <new> or set
302
+ MOCKPIT_PROJECT afterwards if the repo still resolves to the old name.
303
+ --area-order lists the project's areas in the order Home shows them; areas it
304
+ leaves out follow alphabetically, "" goes back to alphabetical.`,
220
305
  run: `mockpit run <file|-> [--session <id>] [--project <name>] [--json]
221
306
  Run a script (the body of an async function, plain JavaScript) in the server's
222
307
  sandbox against the mockpit API; see mockpit guide --topic scripts. Prints each
223
308
  host call in order, the script's prints, its value or error, and any user
224
309
  feedback the run received. --json prints the whole result. Exits 2 when the
225
310
  script failed.`,
226
- watch: `mockpit watch [--session <id>] [--after <seq>]
227
- Stream user feedback, one line per Send, for a background monitor (in
228
- Claude Code, arm it under Monitor after asking, then end the turn).
229
- Delivery is shared with feedback and every write: each Send arrives once.`,
230
- export: `mockpit export --mock <slug> [--state s] [--variant v] [--out <dir>]
231
- Write index.html + history.json per state to .mockpit/accepted/<mock>/<state>/;
232
- with slots, index.html is the chosen screen, plus source.html and decisions.json.`,
311
+ watch: `mockpit watch [--session <id>] [--after <seq>] [--once] [--exit-code <n>]
312
+ Print one line per Send, for a background monitor (in Claude Code, arm it
313
+ under Monitor after asking, then end the turn; with the mockpit plugin, don't,
314
+ since it wakes you itself). A line says a reply landed;
315
+ feedback or the next write delivers it, once.
316
+ --once stops after the first Send, for a harness whose wake is a process
317
+ exiting; --exit-code sets the code it stops with (default 0).`,
318
+ export: `mockpit export --design <slug> [--state s] [--variant v] [--out <dir>]
319
+ Write index.html + history.json per state to .mockpit/accepted/<design>/<state>/;
320
+ with fills, index.html is the chosen state, plus all-fills.html and decisions.json.`,
233
321
  upload: `mockpit upload <file> [--kind image|file] [--session <id>]
234
322
  Upload an asset; prints its id and URL. Use the id as an image surface's assetId.`,
235
323
  guide: `mockpit guide [--topic <id>] [--project <name>]
236
- No flag prints the brief: the loop, parts, asks and knobs, feedback, and
324
+ No flag prints the brief: the loop, parts, questions and knobs, feedback, and
237
325
  this project's palette, kit and icons. --topic prints one reference topic
238
326
  (knobs, asks, surfaces, html, feedback, http, scripts).`,
239
327
  };
@@ -267,7 +355,7 @@ function die(what, fix) {
267
355
  }
268
356
 
269
357
  // `report` decides how a failure reads: plumbing verbs keep the one-line
270
- // `mockpit: …` (exit 1); the mock verbs pass `die` so the error / fix / exit 2
358
+ // `mockpit: …` (exit 1); the design verbs pass `die` so the error / fix / exit 2
271
359
  // format is what an agent parses.
272
360
  async function api(path, init = {}, { report, fix } = {}) {
273
361
  const bail = (what, hint) => (report === "die" ? die(what, hint ?? fix) : fail(what));
@@ -296,7 +384,11 @@ async function api(path, init = {}, { report, fix } = {}) {
296
384
  (body.error ?? `${res.status} ${res.statusText}`) +
297
385
  (Array.isArray(choices) ? ` (${choices.join(", ")})` : "");
298
386
  const extra = issue?.message ?? issue?.code;
299
- bail(extra && !main.includes(extra) ? `${main}: ${extra}` : main);
387
+ // The server's own fix, when it names one, beats the caller's generic hint.
388
+ bail(
389
+ extra && !main.includes(extra) ? `${main}: ${extra}` : main,
390
+ typeof body.fix === "string" ? body.fix : undefined,
391
+ );
300
392
  }
301
393
  return body;
302
394
  }
@@ -390,8 +482,11 @@ function agentName(flags) {
390
482
  // doesn't keep; Claude Code's session id does, so the server can hand back the
391
483
  // same mockpit session (and its feedback cursor).
392
484
  function harnessKey() {
393
- const id = process.env.CLAUDE_CODE_SESSION_ID;
394
- return id ? `claude-code:${id}` : undefined;
485
+ if (process.env.MOCKPIT_SESSION_KEY) return process.env.MOCKPIT_SESSION_KEY;
486
+ if (process.env.CLAUDE_CODE_SESSION_ID)
487
+ return `claude-code:${process.env.CLAUDE_CODE_SESSION_ID}`;
488
+ if (process.env.CODEX_THREAD_ID) return `codex:${process.env.CODEX_THREAD_ID}`;
489
+ return undefined;
395
490
  }
396
491
 
397
492
  async function resolveSession(flags, { create = false } = {}) {
@@ -422,6 +517,19 @@ async function resolveSession(flags, { create = false } = {}) {
422
517
  return session.id;
423
518
  }
424
519
 
520
+ // The harness's conversation id names exactly one session, where the cwd can
521
+ // match several agents working in one repo; the watcher would then take a
522
+ // sibling's replies. Never creates one: most conversations never use mockpit.
523
+ async function resolveSessionByKey(key) {
524
+ try {
525
+ const res = await fetch(`${BASE}/api/sessions${query({ key })}`, { headers: HEADERS });
526
+ if (!res.ok) return null;
527
+ return (await res.json())[0]?.id ?? null;
528
+ } catch {
529
+ return null;
530
+ }
531
+ }
532
+
425
533
  // A monitor process (e.g. the Claude Code plugin) may not share the local
426
534
  // state file written by the agent's CLI calls — different spawn tree, so
427
535
  // `agentPid()` can hash to a different key. Fall back to asking the server for
@@ -620,17 +728,17 @@ async function installIconSets(project, prefixes) {
620
728
  errors.push({ prefix, message: err instanceof Error ? err.message : String(err) });
621
729
  }
622
730
  }
623
- if (added.length === 0) return { added: [], errors, design: null };
624
- const stored = (await api(`${projectPath(project)}/design`)) ?? {};
731
+ if (added.length === 0) return { added: [], errors, designSystem: null };
732
+ const stored = (await api(`${projectPath(project)}/design-system`)) ?? {};
625
733
  const keep = (stored.iconSets ?? []).filter((s) => !added.some((a) => a.prefix === s.prefix));
626
- const design = await api(`${projectPath(project)}/design`, {
734
+ const designSystem = await api(`${projectPath(project)}/design-system`, {
627
735
  method: "PUT",
628
736
  body: JSON.stringify({ ...stored, iconSets: [...keep, ...added] }),
629
737
  });
630
738
  return {
631
- added: design.iconSets.filter((s) => added.some((a) => a.prefix === s.prefix)),
739
+ added: designSystem.iconSets.filter((s) => added.some((a) => a.prefix === s.prefix)),
632
740
  errors,
633
- design,
741
+ designSystem,
634
742
  };
635
743
  }
636
744
 
@@ -774,7 +882,7 @@ function slugify(text) {
774
882
 
775
883
  const projectPath = (project) => `/api/projects/${encodeURIComponent(project)}`;
776
884
 
777
- const mockPath = (slug) => `/api/mocks/${encodeURIComponent(slug)}`;
885
+ const designPath = (slug) => `/api/designs/${encodeURIComponent(slug)}`;
778
886
 
779
887
  const query = (params) => {
780
888
  const q = new URLSearchParams();
@@ -785,18 +893,18 @@ const query = (params) => {
785
893
  return s ? `?${s}` : "";
786
894
  };
787
895
 
788
- // The flags every mock verb shares: which mock, which state, which variant.
789
- const MOCK_FLAGS = {
790
- mock: { type: "string" },
896
+ // The flags every design verb shares: which design, which state, which variant.
897
+ const DESIGN_FLAGS = {
898
+ design: { type: "string" },
791
899
  state: { type: "string" },
792
900
  variant: { type: "string" },
793
901
  project: { type: "string" },
794
902
  session: { type: "string" },
795
903
  };
796
904
 
797
- function requireMock(flags, example) {
798
- const slug = flags.mock ? slugify(flags.mock) : "";
799
- if (!slug) die("--mock needs a slug", example);
905
+ function requireDesign(flags, example) {
906
+ const slug = flags.design ? slugify(flags.design) : "";
907
+ if (!slug) die("--design needs a slug", example);
800
908
  return slug;
801
909
  }
802
910
 
@@ -818,7 +926,7 @@ const shq = (s) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${String(s).replace(/'/g,
818
926
  // The server's suggested ask as the command that sends it: an agent that was
819
927
  // nudged can run it as is. --asks carries ids and scope, which flags can't.
820
928
  function suggestedAskLine(slug, ask) {
821
- return `mockpit ask --mock ${shq(slug)} --asks ${shq(JSON.stringify([ask]))}`;
929
+ return `mockpit ask --design ${shq(slug)} --asks ${shq(JSON.stringify([ask]))}`;
822
930
  }
823
931
 
824
932
  // One line per write: what was published, its URL, the parts found per state,
@@ -826,10 +934,22 @@ function suggestedAskLine(slug, ask) {
826
934
  function printPublished(result, flags) {
827
935
  if (flags.json) return out(result);
828
936
  if (flags.quiet) return;
829
- const { mock, post } = result;
937
+ const { design, post } = result;
938
+ if (result.planned) {
939
+ console.log(`${design.slug}: planned, nothing drawn yet · ${result.url}`);
940
+ if (result.flow) console.log(flowLine(result.flow));
941
+ for (const nudge of result.nudges ?? []) console.log(`nudge: ${nudge}`);
942
+ printFeedback(result.feedback);
943
+ return;
944
+ }
830
945
  const branch = post.from !== undefined && post.from !== post.version - 1;
946
+ const how = result.withdrawn
947
+ ? ` (v${result.withdrawn.version} withdrawn)`
948
+ : flags.amend
949
+ ? " (amended)"
950
+ : "";
831
951
  console.log(
832
- `${mock.slug}/${stateLabel(post.state)}${post.variant} v${post.version}${branch && post.from ? ` (from v${post.from})` : ""} · ${result.url}`,
952
+ `${design.slug}/${stateLabel(post.state)}${post.variant} v${post.version}${branch && post.from ? ` (from v${post.from})` : ""}${how} · ${result.url}`,
833
953
  );
834
954
  for (const { state, parts } of result.parts ?? []) {
835
955
  if (parts.length === 0) continue;
@@ -844,13 +964,41 @@ function printPublished(result, flags) {
844
964
  if (changes?.vanished?.length) console.log(`vanished: ${changes.vanished.join(", ")}`);
845
965
  for (const nudge of result.nudges ?? []) console.log(`nudge: ${nudge}`);
846
966
  if (result.suggestedAsk)
847
- console.log(`suggested: ${suggestedAskLine(mock.slug, result.suggestedAsk)}`);
967
+ console.log(`suggested: ${suggestedAskLine(design.slug, result.suggestedAsk)}`);
848
968
  for (const warning of result.warnings ?? []) console.log(`warning: ${warning}`);
849
969
  printFeedback(result.feedback);
850
970
  }
851
971
 
972
+ async function withdrawVersion(flags, slug, project) {
973
+ const session = await resolveSession(flags);
974
+ if (!session) die(`no session has published ${slug} from here`, `mockpit read ${slug}`);
975
+ const result = await api(
976
+ "/api/designs",
977
+ {
978
+ method: "POST",
979
+ body: JSON.stringify({
980
+ session,
981
+ project,
982
+ design: slug,
983
+ state: flags.state,
984
+ variant: flags.variant,
985
+ withdraw: true,
986
+ }),
987
+ },
988
+ { report: "die", fix: `mockpit read ${slug} --history` },
989
+ );
990
+ if (!result.removed) return printPublished(result, flags);
991
+ if (flags.json) return out(result);
992
+ if (flags.quiet) return;
993
+ const w = result.withdrawn;
994
+ console.log(
995
+ `${slug}/${stateLabel(w.state)}${w.variant} v${w.version} withdrawn, variant removed · ${result.url}`,
996
+ );
997
+ printFeedback(result.feedback);
998
+ }
999
+
852
1000
  function flowLine(flow) {
853
- const steps = flow.steps.map((x) => (x.state === null ? x.mock : `${x.mock}/${x.state}`));
1001
+ const steps = flow.steps.map((x) => (x.state === null ? x.design : `${x.design}/${x.state}`));
854
1002
  return `flow ${flow.name}: ${steps.length ? steps.join(" → ") : "empty, removed"}`;
855
1003
  }
856
1004
 
@@ -860,15 +1008,15 @@ function printFeedback(feedback) {
860
1008
  if (feedback?.length) console.log(`feedback: ${JSON.stringify(feedback)}`);
861
1009
  }
862
1010
 
863
- // What the user is doing right now, per mock.
1011
+ // What the user is doing right now, per design.
864
1012
  function pendingLines(pending) {
865
1013
  return (pending ?? []).flatMap((p) =>
866
1014
  p.draft
867
1015
  ? [
868
- `pending: ${p.mock} — the user is answering (${p.draft.answered} of ${p.draft.of} answered, ${p.draft.comments} comment${p.draft.comments === 1 ? "" : "s"})`,
1016
+ `pending: ${p.design} — the user is answering (${p.draft.answered} of ${p.draft.of} answered, ${p.draft.comments} comment${p.draft.comments === 1 ? "" : "s"})`,
869
1017
  ]
870
1018
  : p.viewerOpen
871
- ? [`pending: ${p.mock} — open in the viewer`]
1019
+ ? [`pending: ${p.design} — open in the viewer`]
872
1020
  : [],
873
1021
  );
874
1022
  }
@@ -878,10 +1026,13 @@ function parsePublishFlags() {
878
1026
  tokens: true,
879
1027
  allowPositionals: true,
880
1028
  options: {
881
- ...MOCK_FLAGS,
1029
+ ...DESIGN_FLAGS,
882
1030
  title: { type: "string" },
883
1031
  kind: { type: "string" },
1032
+ area: { type: "string" },
1033
+ source: { type: "string" },
884
1034
  html: { type: "string" },
1035
+ poster: { type: "string" },
885
1036
  surfaces: { type: "string" },
886
1037
  parts: { type: "string", multiple: true },
887
1038
  fills: { type: "string", multiple: true },
@@ -889,7 +1040,7 @@ function parsePublishFlags() {
889
1040
  from: { type: "string" },
890
1041
  fork: { type: "string" },
891
1042
  prompt: { type: "string" },
892
- note: { type: "string" },
1043
+ description: { type: "string" },
893
1044
  widths: { type: "string" },
894
1045
  flow: { type: "string" },
895
1046
  step: { type: "string" },
@@ -905,19 +1056,21 @@ function parsePublishFlags() {
905
1056
  "session-title": { type: "string" },
906
1057
  agent: { type: "string" },
907
1058
  "new-session": { type: "boolean" },
1059
+ amend: { type: "boolean" },
1060
+ withdraw: { type: "boolean" },
908
1061
  },
909
1062
  });
910
1063
  }
911
1064
 
912
1065
  // `--parts name=file` (repeatable; `name#key` targets one instance): the new
913
1066
  // outer html of one part, spliced server-side into the latest version.
914
- // `--fills part#fill=file` is the same for one alternative of a slot.
1067
+ // `--fills part#fill=file` is the same for one fill of a part.
915
1068
  function readPartFlags(values, slug, flag = "parts") {
916
1069
  if (!values?.length) return undefined;
917
1070
  const example =
918
1071
  flag === "parts"
919
- ? `mockpit publish --mock ${slug} --parts body=body.html`
920
- : `mockpit publish --mock ${slug} --fills entry#skeleton=skeleton.html`;
1072
+ ? `mockpit publish --design ${slug} --parts body=body.html`
1073
+ : `mockpit publish --design ${slug} --fills entry#skeleton=skeleton.html`;
921
1074
  const parts = {};
922
1075
  let stdin = false;
923
1076
  for (const raw of values) {
@@ -939,14 +1092,15 @@ function readPartFlags(values, slug, flag = "parts") {
939
1092
  return parts;
940
1093
  }
941
1094
 
942
- async function publishMock() {
1095
+ async function publishDesign() {
943
1096
  const { values: flags, positionals, tokens } = parsePublishFlags();
944
- const slug = requireMock(flags, "mockpit publish --mock writer --html writer.html");
1097
+ const slug = requireDesign(flags, "mockpit publish --design writer --html writer.html");
945
1098
  const project = resolveProject(flags).name;
946
1099
  const from = flags.from === undefined ? undefined : Number(flags.from);
947
1100
  if (from !== undefined && !Number.isInteger(from)) {
948
1101
  die(`--from must be a version number (got "${flags.from}")`, `mockpit read ${slug} --history`);
949
1102
  }
1103
+ if (flags.withdraw) return withdrawVersion(flags, slug, project);
950
1104
  const step = flags.step === undefined ? undefined : Number(flags.step);
951
1105
  if (step !== undefined && !(Number.isInteger(step) && step >= 0)) {
952
1106
  die(
@@ -971,6 +1125,9 @@ async function publishMock() {
971
1125
  }
972
1126
  // Checked before the session exists so a typo'd path leaves nothing behind on the server.
973
1127
  if (file && file !== "-" && !existsSync(file)) die(`cannot read ${file}`, `ls ${file}`);
1128
+ if (flags.poster !== undefined && !existsSync(flags.poster)) {
1129
+ die(`cannot read ${flags.poster}`, `ls ${flags.poster}`);
1130
+ }
974
1131
  const parts = readPartFlags(flags.parts, slug);
975
1132
  const fills = readPartFlags(flags.fills, slug, "fills");
976
1133
  let surfaces =
@@ -992,21 +1149,26 @@ async function publishMock() {
992
1149
  }
993
1150
  surfaces.push(...(await surfacesFromFlags(flags, tokens, { session, layout: flags.layout })));
994
1151
  }
995
- if (!surfaces && !parts && !fills && !flags.fork && flags.flow === undefined)
996
- die(`no html for ${slug}`, `mockpit publish --mock ${slug} --html <file>`);
1152
+ const declaring = flags.kind === "screen" || flags.kind === "component";
1153
+ if (!surfaces && !parts && !fills && !flags.fork && flags.flow === undefined && !declaring)
1154
+ die(`no html for ${slug}`, `mockpit publish --design ${slug} --html <file>`);
1155
+ const poster =
1156
+ flags.poster === undefined ? undefined : (await uploadFile(flags.poster, { session })).id;
997
1157
  const body = {
998
1158
  session,
999
1159
  project,
1000
- mock: slug,
1160
+ design: slug,
1001
1161
  state: flags.state,
1002
1162
  variant: flags.variant,
1003
1163
  title: flags.title,
1004
1164
  kind: flags.kind,
1165
+ area: flags.area,
1166
+ source: flags.source,
1005
1167
  ...(flags.knobs !== undefined && { knobs: readJsonFlag("knobs", flags.knobs) }),
1006
1168
  ...(from !== undefined && { from }),
1007
1169
  ...(flags.fork !== undefined && { fork: flags.fork }),
1008
1170
  prompt: flags.prompt,
1009
- note: flags.note,
1171
+ description: flags.description,
1010
1172
  ...(flags.widths !== undefined && {
1011
1173
  widths: flags.widths
1012
1174
  .split(",")
@@ -1018,13 +1180,27 @@ async function publishMock() {
1018
1180
  ...(surfaces && { surfaces }),
1019
1181
  ...(parts && { parts }),
1020
1182
  ...(fills && { fills }),
1183
+ ...(poster && { poster }),
1184
+ ...(!poster && findBrowser() && { posterComing: true }),
1185
+ ...(flags.amend && { amend: true }),
1021
1186
  };
1022
1187
  const result = await api(
1023
- "/api/mocks",
1188
+ "/api/designs",
1024
1189
  { method: "POST", body: JSON.stringify(body) },
1025
1190
  { report: "die", fix: `mockpit read ${slug}` },
1026
1191
  );
1027
1192
  printPublished(result, flags);
1193
+ if (!poster && result.post?.surfaces?.some((s) => s.kind === "html") && !result.withdrawn) {
1194
+ queuePoster({
1195
+ base: BASE,
1196
+ token: TOKEN,
1197
+ client: HEADERS["x-mockpit-client"],
1198
+ session,
1199
+ design: result.design.id,
1200
+ postId: result.post.id,
1201
+ version: result.post.version,
1202
+ });
1203
+ }
1028
1204
  return result;
1029
1205
  }
1030
1206
 
@@ -1075,13 +1251,22 @@ function printRun(r) {
1075
1251
 
1076
1252
  function watchLines(batch) {
1077
1253
  const lines = [];
1078
- const name = batch.mock ?? "a mock";
1254
+ const name = batch.design ?? "a design";
1079
1255
  if (batch.handoff) lines.push(`mockpit handoff on ${name}: ${batch.handoff}`);
1080
- if (batch.resolved) {
1081
- const n = batch.dismissed?.length ?? 0;
1082
- lines.push(
1083
- `mockpit resolved ${name}: ${n ? `${n} question${n === 1 ? "" : "s"} dismissed unanswered (${batch.dismissed.map((d) => d.text || d.ask).join("; ")}); don't ask again` : "closed by the user"}`,
1084
- );
1256
+ if (batch.edited) {
1257
+ const e = batch.edited;
1258
+ const bits = [
1259
+ e.kind ? `now a ${e.kind}` : "",
1260
+ e.area !== undefined ? (e.area ? `area ${e.area}` : "no area") : "",
1261
+ ].filter(Boolean);
1262
+ if (bits.length) lines.push(`mockpit edited ${name}: the user moved it (${bits.join(", ")})`);
1263
+ if (e.notes !== undefined) {
1264
+ lines.push(
1265
+ e.notes === null
1266
+ ? `mockpit edited ${name}: the user cleared its notes`
1267
+ : `mockpit edited ${name}: the user edited its notes: ${e.notes}`,
1268
+ );
1269
+ }
1085
1270
  }
1086
1271
  if (batch.reply) {
1087
1272
  const r = batch.reply;
@@ -1090,26 +1275,35 @@ function watchLines(batch) {
1090
1275
  ? r.asks
1091
1276
  .map(
1092
1277
  (a) =>
1093
- `${a.text || a.ask}: ${a.chosen.map((o) => (o.other ? `“${o.label}”` : o.label)).join("+")}${a.note ? ` (“${a.note}”)` : ""}`,
1278
+ `${a.text || a.ask}: ${a.chosen.map((o) => (o.other ? `“${o.label}”` : o.label)).join("+")}${a.comment ? ` (“${a.comment}”)` : ""}`,
1094
1279
  )
1095
1280
  .join("; ")
1096
1281
  : "",
1097
1282
  Object.keys(r.tuned ?? {}).length ? `${Object.keys(r.tuned).length} tuned` : "",
1098
- Object.keys(r.mix ?? {}).length
1099
- ? `mix ${Object.entries(r.mix)
1283
+ Object.keys(r.borrowed ?? {}).length
1284
+ ? `borrowed ${Object.entries(r.borrowed)
1100
1285
  .map(([p, v]) => `${p}←${v}`)
1101
1286
  .join(", ")}`
1102
1287
  : "",
1103
- r.comments?.length ? `${r.comments.length} comment${r.comments.length === 1 ? "" : "s"}` : "",
1288
+ ...(r.comments ?? [])
1289
+ .filter((c) => c.borrow)
1290
+ .map(
1291
+ (c) =>
1292
+ `borrow ${c.borrow.part ? `${c.borrow.part} ` : ""}from ${c.borrow.from} into ${c.variant}@${c.version}${c.text ? ` (“${c.text}”)` : ""}`,
1293
+ ),
1294
+ (() => {
1295
+ const n = (r.comments ?? []).filter((c) => !c.borrow).length;
1296
+ return n ? `${n} comment${n === 1 ? "" : "s"}` : "";
1297
+ })(),
1104
1298
  r.reactions?.length ? r.reactions.join(", ") : "",
1105
- r.decision ? r.decision.kind : "",
1299
+ r.verdict ? `${r.verdict.kind} ${r.verdict.variant}` : "",
1106
1300
  r.text ?? "",
1107
1301
  ].filter(Boolean);
1108
1302
  lines.push(`mockpit reply on ${name}: ${bits.join(" · ").replace(/\s+/g, " ")}`);
1109
1303
  }
1110
- for (const c of batch.comments ?? []) {
1304
+ for (const c of batch.messages ?? []) {
1111
1305
  lines.push(
1112
- `mockpit comment on ${name}: “${String(c.text ?? "")
1306
+ `mockpit message on ${name}: “${String(c.text ?? "")
1113
1307
  .replace(/\s+/g, " ")
1114
1308
  .trim()}”`,
1115
1309
  );
@@ -1117,7 +1311,7 @@ function watchLines(batch) {
1117
1311
  return lines;
1118
1312
  }
1119
1313
 
1120
- // GET where "not there yet" is an answer, not an exit — a project or mock that
1314
+ // GET where "not there yet" is an answer, not an exit — a project or design that
1121
1315
  // doesn't exist is the normal state before the first publish.
1122
1316
  async function apiSoft(path) {
1123
1317
  let res;
@@ -1132,8 +1326,13 @@ async function apiSoft(path) {
1132
1326
  return res.json().catch(() => null);
1133
1327
  }
1134
1328
 
1135
- // Keep the scratch directory out of the repo. Returns true when .gitignore was
1136
- // touched, so init can report it.
1329
+ // Keep the scratch directory out of the repo, all but the project's rules
1330
+ // file, which is meant to be committed. Git can't re-include a file under an
1331
+ // ignored directory, so the ignore has to name the directory's contents, and
1332
+ // the `.mockpit/` line older versions wrote is upgraded in place. Returns true
1333
+ // when .gitignore was touched, so init can report it.
1334
+ const MOCKPIT_IGNORE = ".mockpit/*\n!.mockpit/rules.md";
1335
+
1137
1336
  function ignoreMockpitDir() {
1138
1337
  const file = join(process.cwd(), ".gitignore");
1139
1338
  let current = "";
@@ -1142,14 +1341,56 @@ function ignoreMockpitDir() {
1142
1341
  } catch {
1143
1342
  // no .gitignore yet — create one
1144
1343
  }
1145
- if (/^\.mockpit\/?$/m.test(current)) return false;
1344
+ if (/^\.mockpit\/\*$/m.test(current) && /^!\.mockpit\/rules\.md$/m.test(current)) return false;
1345
+ if (/^\.mockpit\/?$/m.test(current)) {
1346
+ writeFileSync(file, current.replace(/^\.mockpit\/?$/m, MOCKPIT_IGNORE));
1347
+ return true;
1348
+ }
1349
+ const lines = /^\.mockpit\/\*$/m.test(current) ? "!.mockpit/rules.md" : MOCKPIT_IGNORE;
1146
1350
  writeFileSync(
1147
1351
  file,
1148
- current && !current.endsWith("\n") ? `${current}\n.mockpit/\n` : `${current}.mockpit/\n`,
1352
+ current && !current.endsWith("\n") ? `${current}\n${lines}\n` : `${current}${lines}\n`,
1149
1353
  );
1150
1354
  return true;
1151
1355
  }
1152
1356
 
1357
+ function repoRoot() {
1358
+ try {
1359
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
1360
+ encoding: "utf8",
1361
+ stdio: ["ignore", "pipe", "ignore"],
1362
+ }).trim();
1363
+ if (top) return top;
1364
+ } catch {}
1365
+ return process.cwd();
1366
+ }
1367
+
1368
+ // Where `mockpit serve` reads the user's own rules: next to its database.
1369
+ const myRulesFile = () =>
1370
+ join(dirname(process.env.MOCKPIT_DB ?? join(homedir(), ".mockpit", "mockpit.db")), "rules.md");
1371
+
1372
+ // Sends a rules file as it is on disk: a missing file clears the stored copy,
1373
+ // so deleting the file and re-running is how rules are switched off.
1374
+ async function sendRules(file, path) {
1375
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
1376
+ const result = await api(
1377
+ path,
1378
+ { method: "PUT", body: JSON.stringify({ text }) },
1379
+ { report: "die", fix: "mockpit rules --help" },
1380
+ );
1381
+ return { ...result, missing: !text };
1382
+ }
1383
+
1384
+ function rulesSummary(r) {
1385
+ if (r.cleared) return "none (cleared)";
1386
+ const bits = [
1387
+ r.packs?.length ? `packs ${r.packs.join(", ")}` : null,
1388
+ r.rules ? "own rules" : null,
1389
+ ].filter(Boolean);
1390
+ const unknown = r.unknown?.length ? ` · unknown pack ${r.unknown.join(", ")}` : "";
1391
+ return `${bits.join(" · ") || "empty"}${unknown}`;
1392
+ }
1393
+
1153
1394
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1154
1395
 
1155
1396
  // Semver greater-than for plain x.y.z (mirrors server/app.ts versionGt).
@@ -1306,28 +1547,32 @@ const commands = {
1306
1547
  const say = (label, text) => {
1307
1548
  if (!flags.quiet && !flags.json) console.log(`${label.padEnd(8)} ${text}`);
1308
1549
  };
1309
- const { detectDesign, detectIconSets, renderStarter } = await import("./initDesign.js");
1550
+ const { detectDesignSystem, detectIconSets, renderStarter } = await import("./initDesign.js");
1310
1551
  say("project:", `${project} (from ${source})`);
1311
1552
 
1312
- const design = await detectDesign(process.cwd());
1313
- const d = design.detected;
1553
+ const designSystem = await detectDesignSystem(process.cwd());
1554
+ const d = designSystem.detected;
1314
1555
  // `detected` is always an object, so emptiness has to be read off the
1315
1556
  // fields — otherwise a repo with no design system printed a blank line.
1316
- const stripped = design.strippedImports?.length
1317
- ? ` (stripped: ${design.strippedImports.join(", ")})`
1557
+ const stripped = designSystem.strippedImports?.length
1558
+ ? ` (stripped: ${designSystem.strippedImports.join(", ")})`
1318
1559
  : "";
1319
1560
  const found = [
1320
1561
  d?.tailwind &&
1321
- (design.tailwindSource ? `tailwind: ${design.tailwindSource}${stripped}` : "tailwind"),
1562
+ (designSystem.tailwindSource
1563
+ ? `tailwind: ${designSystem.tailwindSource}${stripped}`
1564
+ : "tailwind"),
1322
1565
  d?.shadcn && "shadcn",
1323
- d?.cssVars ? `${d.cssVars} css vars${design.source ? ` from ${design.source}` : ""}` : null,
1566
+ d?.cssVars
1567
+ ? `${d.cssVars} css vars${designSystem.source ? ` from ${designSystem.source}` : ""}`
1568
+ : null,
1324
1569
  d?.fonts?.length ? `fonts ${d.fonts.join(", ")}` : null,
1325
1570
  ].filter(Boolean);
1326
1571
  say(
1327
1572
  "design:",
1328
1573
  found.length ? found.join(" · ") : "nothing detected — using the built-in palette",
1329
1574
  );
1330
- let kit = flags.kit ?? design.kit ?? "builtin";
1575
+ let kit = flags.kit ?? designSystem.kit ?? "builtin";
1331
1576
  // The project kit is stored first so the design PUT can name it as default.
1332
1577
  if (flags["kit-url"] !== undefined) {
1333
1578
  kit = flags.kit ?? "project";
@@ -1338,7 +1583,7 @@ const commands = {
1338
1583
  });
1339
1584
  }
1340
1585
  say("kit:", kit);
1341
- const files = design.designFiles;
1586
+ const files = designSystem.designFiles;
1342
1587
  if (files) {
1343
1588
  const sh = files.shadcn;
1344
1589
  say(
@@ -1353,16 +1598,16 @@ const commands = {
1353
1598
  );
1354
1599
  }
1355
1600
 
1356
- let stored = await api(`${projectPath(project)}/design`, {
1601
+ let stored = await api(`${projectPath(project)}/design-system`, {
1357
1602
  method: "PUT",
1358
1603
  body: JSON.stringify({
1359
- detected: design.detected ?? null,
1360
- palette: design.palette ?? null,
1361
- cssVars: design.cssVars ?? "",
1362
- tailwindCss: design.tailwindCss ?? "",
1363
- strippedImports: design.strippedImports ?? [],
1604
+ detected: designSystem.detected ?? null,
1605
+ palette: designSystem.palette ?? null,
1606
+ cssVars: designSystem.cssVars ?? "",
1607
+ tailwindCss: designSystem.tailwindCss ?? "",
1608
+ strippedImports: designSystem.strippedImports ?? [],
1364
1609
  kit,
1365
- designFiles: design.designFiles ?? null,
1610
+ designFiles: designSystem.designFiles ?? null,
1366
1611
  }),
1367
1612
  });
1368
1613
 
@@ -1385,7 +1630,7 @@ const commands = {
1385
1630
  if (added) say("icons:", `${f.prefix} (${added.count} icons, from ${f.from})`);
1386
1631
  else say("icons:", `${f.prefix} not added: ${error?.message ?? "unknown error"}`);
1387
1632
  }
1388
- if (result.design) stored = result.design;
1633
+ if (result.designSystem) stored = result.designSystem;
1389
1634
  }
1390
1635
  const iconSets = [
1391
1636
  ...new Set([...(stored?.iconSets ?? []).map((s) => s.prefix), ...BUNDLED_ICON_SETS]),
@@ -1394,27 +1639,33 @@ const commands = {
1394
1639
 
1395
1640
  const starter = join(process.cwd(), ".mockpit", "starter.html");
1396
1641
  mkdirSync(dirname(starter), { recursive: true });
1397
- writeFileSync(starter, renderStarter({ ...design, kit }, iconSets));
1642
+ writeFileSync(starter, renderStarter({ ...designSystem, kit }, iconSets));
1398
1643
  say("wrote:", ".mockpit/starter.html");
1399
- if (ignoreMockpitDir()) say("wrote:", ".gitignore (+ .mockpit/)");
1644
+ if (ignoreMockpitDir()) say("wrote:", ".gitignore (+ .mockpit/, keeping .mockpit/rules.md)");
1645
+ const rulesFile = join(repoRoot(), ".mockpit", "rules.md");
1646
+ if (existsSync(rulesFile)) {
1647
+ say("rules:", rulesSummary(await sendRules(rulesFile, `${projectPath(project)}/rules`)));
1648
+ }
1400
1649
  say("next:", "mockpit guide");
1401
- if (flags.json) out({ project, design: stored, starter });
1650
+ if (flags.json) out({ project, designSystem: stored, starter });
1402
1651
  },
1403
1652
 
1404
1653
  async publish() {
1405
- await publishMock();
1654
+ await publishDesign();
1406
1655
  },
1407
1656
 
1408
1657
  async ask() {
1409
1658
  const { values: flags, positionals } = parse({
1410
1659
  allowPositionals: true,
1411
1660
  options: {
1412
- mock: { type: "string" },
1661
+ design: { type: "string" },
1413
1662
  project: { type: "string" },
1414
1663
  session: { type: "string" },
1415
1664
  option: { type: "string", multiple: true },
1665
+ recommend: { type: "string" },
1416
1666
  scope: { type: "string" },
1417
1667
  state: { type: "string" },
1668
+ states: { type: "string", multiple: true },
1418
1669
  part: { type: "string" },
1419
1670
  multi: { type: "boolean" },
1420
1671
  id: { type: "string" },
@@ -1422,13 +1673,16 @@ const commands = {
1422
1673
  asks: { type: "string" },
1423
1674
  },
1424
1675
  });
1425
- const slug = requireMock(flags, 'mockpit ask --mock writer "Which look?" --option Quiet=quiet');
1676
+ const slug = requireDesign(
1677
+ flags,
1678
+ 'mockpit ask --design writer "Quiet or dark?" --option Quiet=quiet',
1679
+ );
1426
1680
  let asks;
1427
1681
  if (flags.asks !== undefined) {
1428
1682
  if (positionals.length > 0) {
1429
1683
  die(
1430
1684
  "use the question or --asks, not both",
1431
- `mockpit ask --mock ${slug} --asks <json|file>`,
1685
+ `mockpit ask --design ${slug} --asks <json|file>`,
1432
1686
  );
1433
1687
  }
1434
1688
  asks = readJsonFlag("asks", flags.asks);
@@ -1438,14 +1692,25 @@ const commands = {
1438
1692
  if (!text)
1439
1693
  die(
1440
1694
  "ask needs a question",
1441
- `mockpit ask --mock ${slug} "Which look?" --option Quiet=quiet`,
1695
+ `mockpit ask --design ${slug} "Quiet or dark?" --option Quiet=quiet`,
1442
1696
  );
1443
1697
  const options = (flags.option ?? []).map(parseOption);
1444
1698
  if (options.length === 0) {
1445
1699
  die(
1446
1700
  "ask needs options",
1447
- `mockpit ask --mock ${slug} "${text}" --option Quiet=quiet --option Dark=dark`,
1701
+ `mockpit ask --design ${slug} "${text}" --option Quiet=quiet --option Dark=dark`,
1702
+ );
1703
+ }
1704
+ if (flags.recommend !== undefined) {
1705
+ const pick = options.find(
1706
+ (o) => o.label.toLowerCase() === flags.recommend.trim().toLowerCase(),
1448
1707
  );
1708
+ if (!pick)
1709
+ die(
1710
+ `--recommend "${flags.recommend}" names no option`,
1711
+ `mockpit ask --design ${slug} "${text}" --option Quiet=quiet --option Dark=dark --recommend Quiet`,
1712
+ );
1713
+ pick.recommended = true;
1449
1714
  }
1450
1715
  asks = [
1451
1716
  {
@@ -1454,6 +1719,7 @@ const commands = {
1454
1719
  ...(flags.id && { id: flags.id }),
1455
1720
  ...(flags.scope && { scope: flags.scope }),
1456
1721
  ...(flags.state && { state: flags.state }),
1722
+ ...(flags.states && { states: flags.states }),
1457
1723
  ...(flags.part && { part: flags.part }),
1458
1724
  ...(flags.multi && { multi: true }),
1459
1725
  ...(flags.after && { after: flags.after }),
@@ -1462,7 +1728,7 @@ const commands = {
1462
1728
  }
1463
1729
  const session = await resolveSession(flags);
1464
1730
  const result = await api(
1465
- `${mockPath(slug)}/asks`,
1731
+ `${designPath(slug)}/asks`,
1466
1732
  {
1467
1733
  method: "POST",
1468
1734
  body: JSON.stringify({
@@ -1477,7 +1743,7 @@ const commands = {
1477
1743
  if (flags.quiet) return;
1478
1744
  for (const ask of result.asks) {
1479
1745
  console.log(
1480
- `asked on ${result.mock}: ${ask.text} [${ask.options.map((o) => o.label).join(" | ")}]`,
1746
+ `asked on ${result.design}: ${ask.text} [${ask.options.map((o) => o.label).join(" | ")}]`,
1481
1747
  );
1482
1748
  }
1483
1749
  for (const nudge of result.nudges ?? []) console.log(`nudge: ${nudge}`);
@@ -1495,10 +1761,20 @@ const commands = {
1495
1761
  body: { type: "boolean" },
1496
1762
  history: { type: "boolean" },
1497
1763
  tree: { type: "boolean" },
1764
+ decisions: { type: "boolean" },
1498
1765
  },
1499
1766
  });
1500
1767
  const project = resolveProject(flags).name;
1501
- if (positionals.length > 1) die("read takes one mock slug", "mockpit read writer");
1768
+ if (positionals.length > 1) die("read takes one design slug", "mockpit read writer");
1769
+ if (flags.decisions) {
1770
+ return out(
1771
+ await api(
1772
+ `/api/projects/${encodeURIComponent(project)}/decisions`,
1773
+ {},
1774
+ { report: "die", fix: `mockpit read --project ${project}` },
1775
+ ),
1776
+ );
1777
+ }
1502
1778
  if (flags.tree) {
1503
1779
  return out(
1504
1780
  await api(
@@ -1512,49 +1788,56 @@ const commands = {
1512
1788
  const slug = slugify(positionals[0]);
1513
1789
  return out(
1514
1790
  await api(
1515
- `${mockPath(slug)}${query({ project, body: flags.body, history: flags.history })}`,
1791
+ `${designPath(slug)}${query({ project, body: flags.body, history: flags.history })}`,
1516
1792
  {},
1517
1793
  { report: "die", fix: `mockpit read --project ${project}` },
1518
1794
  ),
1519
1795
  );
1520
1796
  }
1521
- const list = (await apiSoft(`/api/mocks${query({ project })}`)) ?? {
1522
- mocks: [],
1797
+ const list = (await apiSoft(`/api/designs${query({ project })}`)) ?? {
1798
+ designs: [],
1523
1799
  open: 0,
1524
1800
  pending: [],
1525
1801
  };
1526
1802
  if (flags.json) return out(list);
1527
1803
  if (flags.quiet) return;
1528
- const mocks = list.mocks ?? [];
1804
+ const designs = list.designs ?? [];
1529
1805
  console.log(
1530
- `${project} · ${mocks.length} mock${mocks.length === 1 ? "" : "s"}` +
1806
+ `${project} · ${designs.length} design${designs.length === 1 ? "" : "s"}` +
1531
1807
  (list.open
1532
- ? ` · ${list.open} open ask${list.open === 1 ? "" : "s"}`
1808
+ ? ` · ${list.open} open question${list.open === 1 ? "" : "s"}`
1533
1809
  : " · nothing waiting on the user"),
1534
1810
  );
1535
- for (const m of mocks) {
1811
+ for (const m of designs) {
1536
1812
  const states = m.states.length ? m.states.join(" / ") : "single state";
1537
1813
  console.log(
1538
- ` ${m.slug} · ${m.kind} · ${states} · ${m.variants} variant${m.variants === 1 ? "" : "s"}${m.resolvedAt ? " · resolved" : m.open ? ` · ${m.open} open` : ""}`,
1814
+ ` ${m.slug} · ${m.kind}${m.area ? ` · ${m.area}` : ""} · ${states} · ${m.variants} variant${m.variants === 1 ? "" : "s"}${m.planned ? " · planned" : ""}${m.archivedAt ? " · archived" : m.open ? ` · ${m.open} open` : ""}`,
1539
1815
  );
1540
1816
  }
1541
1817
  for (const flow of list.flows ?? []) console.log(` ${flowLine(flow)}`);
1542
1818
  for (const line of pendingLines(list.pending)) console.log(line);
1819
+ if (list.decisionList) {
1820
+ console.log(
1821
+ `Decided, ${list.decisionList.total}. Treat as settled unless the user says otherwise:`,
1822
+ );
1823
+ for (const d of list.decisionList.lines) console.log(` ${d.design}: ${d.text}`);
1824
+ if (list.decisionList.more) console.log(` ${list.decisionList.more} (--decisions)`);
1825
+ }
1543
1826
  },
1544
1827
  async export() {
1545
1828
  const { values: flags } = parse({
1546
1829
  options: {
1547
- mock: { type: "string" },
1830
+ design: { type: "string" },
1548
1831
  state: { type: "string" },
1549
1832
  variant: { type: "string" },
1550
1833
  project: { type: "string" },
1551
1834
  out: { type: "string" },
1552
1835
  },
1553
1836
  });
1554
- const slug = requireMock(flags, "mockpit export --mock writer");
1837
+ const slug = requireDesign(flags, "mockpit export --design writer");
1555
1838
  const project = resolveProject(flags).name;
1556
1839
  const data = await api(
1557
- `${mockPath(slug)}/export${query({ project, state: flags.state, variant: flags.variant })}`,
1840
+ `${designPath(slug)}/export${query({ project, state: flags.state, variant: flags.variant })}`,
1558
1841
  {},
1559
1842
  { report: "die", fix: `mockpit read ${slug}` },
1560
1843
  );
@@ -1564,8 +1847,8 @@ const commands = {
1564
1847
  const dir = join(root, entry.state === null ? "default" : slugify(entry.state));
1565
1848
  mkdirSync(dir, { recursive: true });
1566
1849
  writeFileSync(join(dir, "index.html"), entry.html ?? "");
1567
- // The screen with every fill, when index.html is the composed choice.
1568
- if (entry.source) writeFileSync(join(dir, "source.html"), entry.source);
1850
+ // The state with every fill, when index.html is the composed choice.
1851
+ if (entry.allFills) writeFileSync(join(dir, "all-fills.html"), entry.allFills);
1569
1852
  const decided = data.decisions?.states?.[entry.state ?? ""];
1570
1853
  if (decided) {
1571
1854
  writeFileSync(
@@ -1578,7 +1861,7 @@ const commands = {
1578
1861
  JSON.stringify(
1579
1862
  {
1580
1863
  project,
1581
- mock: slug,
1864
+ design: slug,
1582
1865
  state: entry.state,
1583
1866
  variant: entry.variant,
1584
1867
  version: entry.version,
@@ -1646,29 +1929,53 @@ const commands = {
1646
1929
  },
1647
1930
  async watch() {
1648
1931
  const { values: flags } = parse({
1649
- options: { session: { type: "string" }, after: { type: "string" } },
1932
+ options: {
1933
+ session: { type: "string" },
1934
+ after: { type: "string" },
1935
+ once: { type: "boolean" },
1936
+ "exit-code": { type: "string" },
1937
+ },
1650
1938
  });
1651
1939
  if (flags.after !== undefined && !/^\d+$/.test(flags.after)) {
1652
1940
  fail(`--after must be a number (got "${flags.after}")`);
1653
1941
  }
1942
+ const exitCode = flags["exit-code"] ?? "0";
1943
+ if (!/^\d+$/.test(exitCode)) fail(`--exit-code must be a number (got "${exitCode}")`);
1944
+ // A waiter outliving its agent would take a Send and tell no one, and each
1945
+ // Send is delivered only once.
1946
+ if (flags.once) {
1947
+ const parent = process.ppid;
1948
+ setInterval(() => {
1949
+ if (process.ppid !== parent || process.ppid === 1) process.exit(0);
1950
+ }, 5000).unref();
1951
+ }
1654
1952
  // A continuous long-poll that streams each piece of feedback as one line —
1655
1953
  // one line is one monitor notification. It re-arms forever; a transient
1656
1954
  // network error backs off and retries rather than exiting.
1657
1955
  //
1658
- // After the first poll it carries no client cursor: an author=user read
1659
- // resumes from the session's server-side cursor and advances it, so
1660
- // feedback is delivered exactly once across watch, feedback, and piggyback.
1956
+ // It only says a reply landed (peek): the reply stays on the session's cursor
1957
+ // until the agent's feedback or next write takes it, so a harness dropping or
1958
+ // cutting this output can't lose it, and each Send is still delivered once.
1661
1959
  let firstAfter = flags.after;
1662
1960
  for (;;) {
1663
- const session = (await resolveSession(flags)) ?? (await resolveSessionByCwd());
1961
+ const key = harnessKey();
1962
+ // The state file is per process and cwd, which several threads of one harness
1963
+ // can share, so a conversation's own key decides whenever there is one.
1964
+ const session =
1965
+ flags.session ??
1966
+ process.env.MOCKPIT_SESSION ??
1967
+ (key
1968
+ ? await resolveSessionByKey(key)
1969
+ : ((await resolveSession(flags)) ?? (await resolveSessionByCwd())));
1664
1970
  if (!session) {
1665
- await sleep(2000);
1971
+ // A conversation that hasn't used mockpit yet may never; ask rarely.
1972
+ await sleep(key ? 10_000 : 2000);
1666
1973
  continue;
1667
1974
  }
1668
1975
  let result;
1669
1976
  try {
1670
1977
  const res = await fetch(
1671
- `${BASE}/api/comments${query({ session, author: "user", after: firstAfter, wait: 60 })}`,
1978
+ `${BASE}/api/comments${query({ session, author: "user", after: firstAfter, wait: 60, peek: 1 })}`,
1672
1979
  { headers: HEADERS },
1673
1980
  );
1674
1981
  if (!res.ok) {
@@ -1681,20 +1988,26 @@ const commands = {
1681
1988
  continue;
1682
1989
  }
1683
1990
  firstAfter = undefined;
1684
- for (const batch of result.feedback ?? []) {
1685
- for (const line of watchLines(batch)) console.log(line);
1991
+ const lines = (result.feedback ?? []).flatMap(watchLines);
1992
+ for (const line of lines) console.log(line);
1993
+ if (flags.once && lines.length) {
1994
+ // A harness may frame this output as a hook failure, so it says what it is.
1995
+ console.log(
1996
+ "This is the user's reply from the mockpit viewer, not an error. Call mockpit's feedback for all of it.",
1997
+ );
1998
+ process.exit(Number(exitCode));
1686
1999
  }
1687
2000
  }
1688
2001
  },
1689
2002
 
1690
2003
  async say() {
1691
- const { values: flags, positionals } = parse({ allowPositionals: true, options: MOCK_FLAGS });
2004
+ const { values: flags, positionals } = parse({ allowPositionals: true, options: DESIGN_FLAGS });
1692
2005
  const text = positionals.join(" ").trim();
1693
- if (!text) die("say needs text", 'mockpit say "…" --mock writer');
1694
- const slug = requireMock(flags, `mockpit say "${text}" --mock writer`);
2006
+ if (!text) die("say needs text", 'mockpit say "…" --design writer');
2007
+ const slug = requireDesign(flags, `mockpit say "${text}" --design writer`);
1695
2008
  const session = await resolveSession(flags);
1696
2009
  const result = await api(
1697
- `${mockPath(slug)}/say`,
2010
+ `${designPath(slug)}/say`,
1698
2011
  {
1699
2012
  method: "POST",
1700
2013
  body: JSON.stringify({
@@ -1712,6 +2025,169 @@ const commands = {
1712
2025
  console.log(`said on ${slug}`);
1713
2026
  printFeedback(result.feedback);
1714
2027
  },
2028
+ async move() {
2029
+ const { values: flags } = parse({
2030
+ options: {
2031
+ ...DESIGN_FLAGS,
2032
+ variant: { type: "string", multiple: true },
2033
+ to: { type: "string" },
2034
+ "to-state": { type: "string" },
2035
+ archive: { type: "boolean" },
2036
+ restore: { type: "boolean" },
2037
+ "hide-older": { type: "boolean" },
2038
+ "delete-older": { type: "boolean" },
2039
+ },
2040
+ });
2041
+ const example = "mockpit move --design writer --variant quiet --archive";
2042
+ const slug = requireDesign(flags, example);
2043
+ const tidying = flags["hide-older"] || flags["delete-older"];
2044
+ if (!flags.variant?.length && !tidying) die("move needs --variant", example);
2045
+ const session = await resolveSession(flags);
2046
+ const result = await api(
2047
+ `${designPath(slug)}/move`,
2048
+ {
2049
+ method: "POST",
2050
+ body: JSON.stringify({
2051
+ state: flags.state,
2052
+ variants: flags.variant,
2053
+ to: flags.to,
2054
+ toState: flags["to-state"],
2055
+ archive: flags.archive,
2056
+ restore: flags.restore,
2057
+ hideOlder: flags["hide-older"],
2058
+ deleteOlder: flags["delete-older"],
2059
+ project: resolveProject(flags).name,
2060
+ session: session ?? undefined,
2061
+ }),
2062
+ },
2063
+ { report: "die", fix: `mockpit read ${slug}` },
2064
+ );
2065
+ if (flags.json) return out(result);
2066
+ if (flags.quiet) return;
2067
+ if (tidying) {
2068
+ const verb = flags["delete-older"] ? "deleted" : "hid";
2069
+ console.log(`${verb} ${result.versions.length} older versions`);
2070
+ for (const s of result.skipped) {
2071
+ const which = s.version ? ` v${s.version}` : "";
2072
+ console.log(`stayed: ${s.state ? `${s.state}/` : ""}${s.variant}${which} (${s.why})`);
2073
+ }
2074
+ printFeedback(result.feedback);
2075
+ return;
2076
+ }
2077
+ const names = result.variants.map((v) => v.variant).join(", ") || "nothing";
2078
+ const done = result.to
2079
+ ? `moved to ${result.to.design}`
2080
+ : flags.restore
2081
+ ? "restored"
2082
+ : "archived";
2083
+ console.log(`${done}: ${names}`);
2084
+ for (const s of result.skipped) console.log(`stayed: ${s.variant} (${s.why})`);
2085
+ printFeedback(result.feedback);
2086
+ },
2087
+ async edit() {
2088
+ const { values: flags } = parse({
2089
+ options: {
2090
+ design: { type: "string" },
2091
+ project: { type: "string" },
2092
+ session: { type: "string" },
2093
+ slug: { type: "string" },
2094
+ title: { type: "string" },
2095
+ kind: { type: "string" },
2096
+ area: { type: "string" },
2097
+ source: { type: "string" },
2098
+ "first-state": { type: "string" },
2099
+ notes: { type: "string" },
2100
+ rename: { type: "string" },
2101
+ "area-order": { type: "string" },
2102
+ },
2103
+ });
2104
+ const project = resolveProject(flags).name;
2105
+ const session = (await resolveSession(flags)) ?? undefined;
2106
+ const areaOrder = flags["area-order"];
2107
+ if (flags.rename !== undefined || areaOrder !== undefined) {
2108
+ if (flags.design) {
2109
+ die(
2110
+ "--rename and --area-order edit the project; edit the design without them",
2111
+ "mockpit edit --help",
2112
+ );
2113
+ }
2114
+ if (flags.rename !== undefined && !flags.rename.trim()) {
2115
+ die("--rename needs the new name", `mockpit edit --rename <name>`);
2116
+ }
2117
+ const result = await api(
2118
+ projectPath(project),
2119
+ {
2120
+ method: "PATCH",
2121
+ body: JSON.stringify({
2122
+ name: flags.rename,
2123
+ ...(areaOrder === undefined ? {} : { areaOrder: areaOrder.split(",") }),
2124
+ session,
2125
+ }),
2126
+ },
2127
+ { report: "die", fix: "mockpit read" },
2128
+ );
2129
+ if (flags.json) return out(result);
2130
+ if (flags.quiet) return;
2131
+ if (result.areaOrder) {
2132
+ console.log(
2133
+ `area order ${result.areaOrder.length ? result.areaOrder.join(", ") : "alphabetical"} · ${result.url}`,
2134
+ );
2135
+ }
2136
+ for (const nudge of result.nudges ?? []) console.log(`nudge: ${nudge}`);
2137
+ if (!result.renamed) return printFeedback(result.feedback);
2138
+ console.log(
2139
+ `renamed project ${result.renamed.from} → ${result.renamed.to} · ${result.designs} design${result.designs === 1 ? "" : "s"} · ${result.url}`,
2140
+ );
2141
+ if (!flags.project && !process.env.MOCKPIT_PROJECT) {
2142
+ console.log(
2143
+ `note: this repo still resolves to "${project}"; pass --project ${shq(result.project)} or set MOCKPIT_PROJECT`,
2144
+ );
2145
+ }
2146
+ printFeedback(result.feedback);
2147
+ return;
2148
+ }
2149
+ const slug = requireDesign(flags, "mockpit edit --design writer --area Shell");
2150
+ const fields = ["slug", "title", "kind", "area", "source", "notes"].filter(
2151
+ (k) => flags[k] !== undefined,
2152
+ );
2153
+ if (!fields.length && flags["first-state"] === undefined) {
2154
+ die(
2155
+ "edit needs --slug, --title, --kind, --area, --source, --notes or --first-state",
2156
+ "mockpit edit --help",
2157
+ );
2158
+ }
2159
+ const result = await api(
2160
+ designPath(slug),
2161
+ {
2162
+ method: "PATCH",
2163
+ body: JSON.stringify({
2164
+ project,
2165
+ session,
2166
+ ...Object.fromEntries(fields.map((k) => [k, flags[k]])),
2167
+ firstState: flags["first-state"],
2168
+ }),
2169
+ },
2170
+ { report: "die", fix: `mockpit read ${slug}` },
2171
+ );
2172
+ if (flags.json) return out(result);
2173
+ if (flags.quiet) return;
2174
+ const m = result.design;
2175
+ console.log(
2176
+ [
2177
+ result.renamed ? `${result.renamed.from} → ${m.slug}` : m.slug,
2178
+ m.kind,
2179
+ m.area && `area ${m.area}`,
2180
+ m.source && `source ${m.source}`,
2181
+ result.notes && (result.notes.applied ? "notes saved" : "notes NOT applied"),
2182
+ result.url,
2183
+ ]
2184
+ .filter(Boolean)
2185
+ .join(" · "),
2186
+ );
2187
+ for (const nudge of result.nudges ?? []) console.log(`nudge: ${nudge}`);
2188
+ if (result.notes && !result.notes.applied) console.log(`notes: ${result.notes.text}`);
2189
+ printFeedback(result.feedback);
2190
+ },
1715
2191
  async demo() {
1716
2192
  parse();
1717
2193
  const { DEMO } = await import("./demoData.js");
@@ -1723,12 +2199,13 @@ const commands = {
1723
2199
  });
1724
2200
  for (const state of DEMO.states) {
1725
2201
  for (const variant of DEMO.variants) {
1726
- await api("/api/mocks", {
2202
+ await api("/api/designs", {
1727
2203
  method: "POST",
1728
2204
  body: JSON.stringify({
1729
2205
  session: session.id,
1730
2206
  project: DEMO.project,
1731
- mock: DEMO.slug,
2207
+ design: DEMO.slug,
2208
+ kind: "screen",
1732
2209
  title: DEMO.title,
1733
2210
  state: state.label,
1734
2211
  variant: variant.name,
@@ -1738,7 +2215,7 @@ const commands = {
1738
2215
  });
1739
2216
  }
1740
2217
  }
1741
- await api(`${mockPath(DEMO.slug)}/asks`, {
2218
+ await api(`${designPath(DEMO.slug)}/asks`, {
1742
2219
  method: "POST",
1743
2220
  body: JSON.stringify({ project: DEMO.project, session: session.id, asks: DEMO.asks }),
1744
2221
  });
@@ -1791,14 +2268,14 @@ const commands = {
1791
2268
  if (sub === "add") {
1792
2269
  const result = await installIconSets(project, sets);
1793
2270
  if (result.errors.length) fail(result.errors.map((e) => e.message).join("; "));
1794
- if (flags.json) return out(result.design);
2271
+ if (flags.json) return out(result.designSystem);
1795
2272
  for (const a of result.added) {
1796
2273
  if (!flags.quiet) console.log(`added ${a.prefix} (${a.count} icons)`);
1797
2274
  }
1798
2275
  return;
1799
2276
  }
1800
2277
  if (sub === "remove") {
1801
- const stored = await api(`${projectPath(project)}/design`);
2278
+ const stored = await api(`${projectPath(project)}/design-system`);
1802
2279
  const installed = stored?.iconSets ?? [];
1803
2280
  for (const set of sets) {
1804
2281
  if (installed.some((s) => s.prefix === set)) continue;
@@ -1808,14 +2285,14 @@ const commands = {
1808
2285
  : `${set} is not installed for ${project}`,
1809
2286
  );
1810
2287
  }
1811
- const design = await api(`${projectPath(project)}/design`, {
2288
+ const designSystem = await api(`${projectPath(project)}/design-system`, {
1812
2289
  method: "PUT",
1813
2290
  body: JSON.stringify({
1814
2291
  ...stored,
1815
2292
  iconSets: installed.filter((s) => !sets.includes(s.prefix)),
1816
2293
  }),
1817
2294
  });
1818
- if (flags.json) return out(design);
2295
+ if (flags.json) return out(designSystem);
1819
2296
  if (!flags.quiet) console.log(`removed ${sets.join(", ")}`);
1820
2297
  return;
1821
2298
  }
@@ -1842,13 +2319,13 @@ const commands = {
1842
2319
  const { name: project } = resolveProject(flags);
1843
2320
  if ((sub !== "add" && sub !== "remove") || !id)
1844
2321
  fail("usage: mockpit kit add <id> --url <url> --doc <file|text> | mockpit kit remove <id>");
1845
- const design =
2322
+ const designSystem =
1846
2323
  sub === "add"
1847
2324
  ? await putProjectKit(project, id, flags)
1848
2325
  : await api(`${projectPath(project)}/kits/${encodeURIComponent(id)}`, {
1849
2326
  method: "DELETE",
1850
2327
  });
1851
- if (flags.json) return out(design);
2328
+ if (flags.json) return out(designSystem);
1852
2329
  if (!flags.quiet) console.log(`${sub === "add" ? "added" : "removed"} kit ${id} (${project})`);
1853
2330
  },
1854
2331
 
@@ -1860,6 +2337,24 @@ const commands = {
1860
2337
  return printBrief(flags);
1861
2338
  },
1862
2339
 
2340
+ // A rules file is the user's or the project's own guidance on top of the
2341
+ // core brief; the brief prints it, so this only has to put it on the server.
2342
+ async rules() {
2343
+ const { values: flags } = parse({
2344
+ options: { mine: { type: "boolean" }, project: { type: "string" } },
2345
+ });
2346
+ const mine = !!flags.mine;
2347
+ const file = mine ? myRulesFile() : join(repoRoot(), ".mockpit", "rules.md");
2348
+ const project = mine ? null : resolveProject(flags).name;
2349
+ const result = await sendRules(file, mine ? "/api/rules" : `${projectPath(project)}/rules`);
2350
+ if (flags.json) return out(result);
2351
+ if (flags.quiet) return;
2352
+ const where = mine ? "your rules" : `project ${project}`;
2353
+ const from = result.missing ? `no ${file}` : file;
2354
+ console.log(`${where}: ${rulesSummary(result)} (from ${from})`);
2355
+ if (!result.missing) console.log("next:", "mockpit guide");
2356
+ },
2357
+
1863
2358
  async setup() {
1864
2359
  parse();
1865
2360
  console.log(await fetchTextWithFallback("/setup", join(ROOT, "guide", "AGENT_SETUP.md")));