mockpit 1.3.0 → 2.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 (45) hide show
  1. package/README.md +61 -45
  2. package/bin/demoData.js +3 -3
  3. package/bin/mockpit.js +327 -52
  4. package/dist/mcp/server.js +35 -0
  5. package/dist/server/apiViews.d.ts +308 -23
  6. package/dist/server/apiViews.js +60 -19
  7. package/dist/server/app.d.ts +4 -1
  8. package/dist/server/app.js +608 -119
  9. package/dist/server/codeWorker.js +2 -1
  10. package/dist/server/decisions.d.ts +5 -5
  11. package/dist/server/decisions.js +10 -9
  12. package/dist/server/designGuide.d.ts +6 -2
  13. package/dist/server/designGuide.js +77 -43
  14. package/dist/server/embeds.d.ts +1 -0
  15. package/dist/server/embeds.js +30 -4
  16. package/dist/server/feedbackBatch.d.ts +6 -6
  17. package/dist/server/feedbackBatch.js +18 -11
  18. package/dist/server/guidance.d.ts +23 -0
  19. package/dist/server/guidance.js +71 -0
  20. package/dist/server/index.js +10 -1
  21. package/dist/server/mcpHttp.d.ts +3 -0
  22. package/dist/server/mcpHttp.js +32 -0
  23. package/dist/server/mcpSpec.d.ts +2 -1
  24. package/dist/server/mcpSpec.js +74 -24
  25. package/dist/server/postSurfaces.js +3 -0
  26. package/dist/server/run.js +6 -1
  27. package/dist/server/runApi.d.ts +3 -3
  28. package/dist/server/runApi.js +16 -12
  29. package/dist/server/sqlStore.d.ts +10 -3
  30. package/dist/server/sqlStore.js +230 -16
  31. package/dist/server/types.d.ts +46 -17
  32. package/dist/server/types.js +34 -11
  33. package/extensions/mockpit.js +179 -16
  34. package/guide/packs/design-craft.md +35 -0
  35. package/guide/topics/asks.md +60 -56
  36. package/guide/topics/feedback.md +104 -74
  37. package/guide/topics/html.md +5 -11
  38. package/guide/topics/http.md +6 -3
  39. package/guide/topics/knobs.md +2 -2
  40. package/guide/topics/scripts.md +2 -2
  41. package/guide/topics/surfaces.md +28 -10
  42. package/package.json +1 -4
  43. package/skills/launch-video/SKILL.md +1 -1
  44. package/skills/mockpit/SKILL.md +18 -10
  45. package/viewer/dist/index.html +8 -7
package/bin/mockpit.js CHANGED
@@ -9,7 +9,7 @@ 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";
@@ -29,23 +29,23 @@ const HEADERS = {
29
29
  const HELP = `mockpit — a live visual surface for terminal coding agents
30
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
32
+ mock is a sketch, page or component, by slug; a state is one thing the user sees in it ("Lab
33
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.
34
+ Declare the parts you want feedback on with data-part="name" in the html.
35
35
 
36
36
  the loop (never blocks on the user):
37
37
  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
38
+ 2. mockpit publish once per variant; declare parts with data-part.
39
+ 3. A choice is several variants plus one question that binds them: mockpit ask
40
40
  with options bound to variants. A variant decides everything it differs
41
41
  on; don't re-ask it. Visual options carry a variant or knob set. One
42
42
  render plus a control: --knobs.
43
43
  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
44
+ itself lives in the mock, never in chat. The user answers in the
45
45
  browser at their own pace. Never poll.
46
46
  5. When the user says they answered (or a mockpit watch line wakes you), run
47
47
  mockpit feedback. Every write also returns feedback; read it.
48
- 6. mockpit publish the revision; mockpit export once a variant is accepted.
48
+ 6. mockpit publish the revision; mockpit export once a variant is in use.
49
49
  In Claude Code, arm mockpit watch under Monitor after asking, then end the
50
50
  turn; elsewhere the user's next message is the wake-up.
51
51
 
@@ -56,25 +56,31 @@ verbs:
56
56
  mockpit publish --mock <slug> --html <file> [options]
57
57
  create or version one variant of one state
58
58
  --state <label> state, in the user's words (omit for a single-state mock)
59
+ --situation <s> another render of that state ("Couldn't save", "Loading")
59
60
  --variant <name> variant label (default "default")
60
61
  --html <file|-> the html body (or a positional file)
61
62
  --surfaces <json|file> instead: the full ordered surface list; {"id"}
62
63
  alone keeps a surface, a missing id removes it
63
64
  --parts <name=file|-> instead: replace just that data-part's element in
64
65
  the latest version (repeatable; name#key targets one instance)
65
- --fills <part#fill=file|-> instead: replace or add one fill of a slot in
66
+ --fills <part#fill=file|-> instead: replace or add one fill of a part in
66
67
  the latest version (repeatable)
67
68
  --title <t> mock title
68
- --kind <k> component|page (default component)
69
+ --kind <k> page|component, only for an app's screens (default sketch)
70
+ --area <name> where in the product it belongs ("Shell"); Home groups by it
71
+ --source <path> the code path that implements it
69
72
  --knobs <json|file> knobs in tunekit usePane shape, keyed by path
70
73
  --from <N> branch from version N
71
74
  --prompt <text> what prompted this version
72
75
  --note <text> one line on what this variant is, shown beside it
73
76
  --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
77
+ --flow <name> [--step <N>] make this state step N of a flow (0 takes
78
+ it out); with no html it only places the state
76
79
  --project <name> project (default: git remote, else directory name)
77
80
  --kit <id> opt the html surface into a kit (repeatable; see "mockpit kits")
81
+ --poster <file> a still of the page (png, jpg); Studio and Home show it
82
+ instead of running the page. Send one with every version
83
+ that changes the page, or it goes
78
84
  --md/--mermaid/--diff/--terminal/--data/--code/--image <file|->
79
85
  add a surface of that kind after the html (repeatable;
80
86
  --data is a JSON tree)
@@ -82,12 +88,13 @@ verbs:
82
88
  ask the user; bind options to variants
83
89
  --option <l[=v]> an option (repeatable); "=variant" binds it to a variant,
84
90
  "=variant@3" to its version 3, "=knob:path=value" sets one knob
91
+ --recommend <l> the option you would pick, by label; a quiet mark on it
85
92
  --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
93
+ --state <label> the state a state-scoped question is about
94
+ --part <name> the part a part-scoped question is about
88
95
  --multi allow several answers
89
- --id <id> stable id; reusing one replaces that ask
90
- --asks <json|file> several asks at once, full shape
96
+ --id <id> stable id; reusing one replaces that question
97
+ --asks <json|file> several questions at once, full shape
91
98
  mockpit read [slug] [--body] [--history]
92
99
  no slug: every mock plus what the user
93
100
  is doing now; a slug: that mock
@@ -95,12 +102,20 @@ verbs:
95
102
  plus pending; returns at once
96
103
  mockpit say "<text>" --mock <slug> [--state s] [--variant v]
97
104
  a plain-text message in a mock's thread
105
+ mockpit edit --mock <slug> [--slug s] [--title t] [--kind k] [--area a] [--source p]
106
+ change a mock without a new version ("" clears)
107
+ mockpit edit --project <name> --rename <new>
108
+ rename a project
109
+ mockpit edit --project <name> --area-order "Shell,Food"
110
+ the order Home lists areas in
98
111
  mockpit export --mock <slug> [--state s] [--variant v] [--out <dir>]
99
- write the accepted html + history per state
112
+ write the html in use + history per state
100
113
  to .mockpit/accepted/<mock>/<state>/
101
114
  mockpit upload <file> [--kind image|file]
102
115
  upload an asset, print its id and URL
103
116
  mockpit guide [--topic <id>] the project-aware brief, or one reference topic
117
+ mockpit rules [--mine] send .mockpit/rules.md (or, with --mine, your
118
+ ~/.mockpit/rules.md) to the server for the brief
104
119
  mockpit run <file|-> run a script against the mockpit API on the
105
120
  server (mockpit guide --topic scripts)
106
121
  mockpit watch [--session <id>] stream user feedback, one line per Send
@@ -148,6 +163,15 @@ const COMMAND_HELP = {
148
163
  (gitignored). --kit makes a bundled or project kit the default for every html
149
164
  surface. --kit-url defines the project's own kit (id from --kit, else
150
165
  "project") and makes it the default; the doc is the cheat sheet the brief prints.`,
166
+ rules: `mockpit rules [--project <name>]
167
+ mockpit rules --mine
168
+ Guidance on top of the core brief, highest first: the project's
169
+ .mockpit/rules.md (committed with the repo), your ~/.mockpit/rules.md, then
170
+ the packs either names (\`packs: [design-craft]\` in its frontmatter). A file
171
+ has "## Rules" (always followed) and "## Suggestions" (aimed for).
172
+ This sends the file as it is on disk; a missing file clears the stored copy.
173
+ init sends the project's too. A local server reads yours live, so --mine is
174
+ for a deployed one.`,
151
175
  kit: `mockpit kit add <id> --url <https url> --doc <file.md|text> [--script <url>]
152
176
  mockpit kit remove <id>
153
177
  A project kit is a stylesheet (and optional script) on the CDN allowlist plus a
@@ -165,6 +189,8 @@ mockpit publish --mock <slug> --parts <name=file|-> ... [options]
165
189
  mockpit publish --mock <slug> --fills <part#fill=file|-> ... [options]
166
190
  Creates the mock, state or variant, or the next version of an existing one.
167
191
  --state <label> state in the user's words (omit for a single-state mock)
192
+ --situation <s> another render of that state ("Couldn't save", "Loading"),
193
+ after the state itself is published
168
194
  --variant <name> variant label (default "default")
169
195
  --surfaces <json|file> the full ordered list: {"id"} alone keeps a surface,
170
196
  a missing id removes it, order is the list order
@@ -172,9 +198,14 @@ mockpit publish --mock <slug> --fills <part#fill=file|-> ... [options]
172
198
  (name#key for one instance) in the latest version; repeatable
173
199
  --fills <part#fill=file|-> replace the element carrying data-part="part"
174
200
  data-fill="fill" in the latest version, or add it after
175
- that slot's other fills; the marks are added if missing
201
+ that part's other fills; the marks are added if missing
176
202
  --title <t> mock title
177
- --kind <k> component|page (default component)
203
+ --kind <k> page|component|sketch: page for a screen of the product,
204
+ component for a piece pages embed (embedding makes one);
205
+ omit for sketches, diagrams, reviews (default sketch)
206
+ --area <name> where in the product it belongs, in the project's words
207
+ ("Shell", "Food"); Home groups each tab by it. "" clears
208
+ --source <path> the code path that implements it (apps/web/src/shell). "" clears
178
209
  --knobs <json|file> knobs in tunekit usePane shape
179
210
  --from <N> branch from version N
180
211
  --fork <v[@N]> start this new variant from sibling v (its version N, else
@@ -185,38 +216,54 @@ mockpit publish --mock <slug> --fills <part#fill=file|-> ... [options]
185
216
  inside the design)
186
217
  --widths <list> device widths the mock is judged at, side by side, e.g.
187
218
  390,1280 (the ones the user ships to)
188
- --flow <name> a flow this screen is a step of: the screens the user moves
219
+ --flow <name> a flow this state is a step of: the states the user moves
189
220
  between, in order, across mocks; without html it only
190
- places the existing screen
221
+ places the existing state
191
222
  --step <N> its 1-based place in the flow (moves it if it is there);
192
223
  0 takes it out; omitted, it joins at the end
193
224
  --project <name> project (default: git remote, else directory name)
225
+ --poster <file> a still of the page (png, jpg) that Studio and Home show
226
+ instead of running it; with --parts or --fills it is the
227
+ still of the spliced page. A version that changes the page
228
+ without one drops the old still
194
229
  --md/--mermaid/--diff/--terminal/--data/--code/--image <file> more surfaces
195
230
  --json / --quiet
196
231
  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.`,
232
+ nudges, a ready-to-run "mockpit ask" when variants have no question, and feedback.`,
198
233
  ask: `mockpit ask --mock <slug> "<question>" --option <label[=variant]> ... [--scope mock|state|part]
199
- [--state s] [--part p] [--multi] [--id id] [--after <ask id>]
234
+ [--state s] [--part p] [--multi] [--id id] [--after <ask id>] [--recommend <label>]
200
235
  mockpit ask --mock <slug> --asks <json|file>
201
- Ask the user. A choice is several variants plus one ask that binds them.
236
+ Ask the user. A choice is several variants plus one question that binds them.
202
237
  Then tell the user where to look and end your turn.`,
203
238
  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
239
+ No slug: one line per mock (states, variants, open questions), one per flow, plus pending, what
240
+ the user is doing now. A slug: the mock's states, variants, questions with
206
241
  answers, parts, knobs and settled values, plus its pending, as JSON.
207
242
  --body includes surfaces, --history version rows. --tree (no slug): the
208
243
  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.`,
244
+ mocks it embeds and is embedded in, as JSON. Never consumes feedback.`,
210
245
  feedback: `mockpit feedback [--project <name>] [--session <id>]
211
246
  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
247
+ sent since you last heard ({mock, reply: {asks, borrowed, tuned, comments,
248
+ verdict}, messages, inUse, archived}, or {mock, closed: true, dismissed} when the
214
249
  user closed it unanswered), delivered once across feedback, watch and
215
250
  every write; and pending ({mock, viewerOpen, draft}) so you can tell the
216
251
  user "take your time" or "2 of 3 answered". Run it when the user says they
217
252
  answered.`,
218
253
  say: `mockpit say "<text>" --mock <slug> [--state s] [--variant v]
219
254
  A plain-text message in the mock's thread, as your session's agent.`,
255
+ edit: `mockpit edit --mock <slug> [--slug <new>] [--title <t>] [--kind page|component|sketch]
256
+ [--area <name>|""] [--source <path>|""] [--project <name>]
257
+ mockpit edit --project <name> --rename <new name>
258
+ mockpit edit --project <name> --area-order "Shell,Food,Chat"
259
+ Change a mock without publishing a new version; "" clears area or source. A new
260
+ slug rewrites every embed, data-goto and flow step that names the old one, in
261
+ every version. A mock still embedded somewhere stays a component, not a sketch.
262
+ --rename moves the project's mocks, sessions, flows and design settings to the
263
+ new name (refused if that project has mocks); pass --project <new> or set
264
+ MOCKPIT_PROJECT afterwards if the repo still resolves to the old name.
265
+ --area-order lists the project's areas in the order Home shows them; areas it
266
+ leaves out follow alphabetically, "" goes back to alphabetical.`,
220
267
  run: `mockpit run <file|-> [--session <id>] [--project <name>] [--json]
221
268
  Run a script (the body of an async function, plain JavaScript) in the server's
222
269
  sandbox against the mockpit API; see mockpit guide --topic scripts. Prints each
@@ -229,11 +276,11 @@ mockpit publish --mock <slug> --fills <part#fill=file|-> ... [options]
229
276
  Delivery is shared with feedback and every write: each Send arrives once.`,
230
277
  export: `mockpit export --mock <slug> [--state s] [--variant v] [--out <dir>]
231
278
  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.`,
279
+ with fills, index.html is the chosen state, plus all-fills.html and decisions.json.`,
233
280
  upload: `mockpit upload <file> [--kind image|file] [--session <id>]
234
281
  Upload an asset; prints its id and URL. Use the id as an image surface's assetId.`,
235
282
  guide: `mockpit guide [--topic <id>] [--project <name>]
236
- No flag prints the brief: the loop, parts, asks and knobs, feedback, and
283
+ No flag prints the brief: the loop, parts, questions and knobs, feedback, and
237
284
  this project's palette, kit and icons. --topic prints one reference topic
238
285
  (knobs, asks, surfaces, html, feedback, http, scripts).`,
239
286
  };
@@ -422,6 +469,19 @@ async function resolveSession(flags, { create = false } = {}) {
422
469
  return session.id;
423
470
  }
424
471
 
472
+ // The harness's conversation id names exactly one session, where the cwd can
473
+ // match several agents working in one repo; the watcher would then take a
474
+ // sibling's replies. Never creates one: most conversations never use mockpit.
475
+ async function resolveSessionByKey(key) {
476
+ try {
477
+ const res = await fetch(`${BASE}/api/sessions${query({ key })}`, { headers: HEADERS });
478
+ if (!res.ok) return null;
479
+ return (await res.json())[0]?.id ?? null;
480
+ } catch {
481
+ return null;
482
+ }
483
+ }
484
+
425
485
  // A monitor process (e.g. the Claude Code plugin) may not share the local
426
486
  // state file written by the agent's CLI calls — different spawn tree, so
427
487
  // `agentPid()` can hash to a different key. Fall back to asking the server for
@@ -879,9 +939,13 @@ function parsePublishFlags() {
879
939
  allowPositionals: true,
880
940
  options: {
881
941
  ...MOCK_FLAGS,
942
+ situation: { type: "string" },
882
943
  title: { type: "string" },
883
944
  kind: { type: "string" },
945
+ area: { type: "string" },
946
+ source: { type: "string" },
884
947
  html: { type: "string" },
948
+ poster: { type: "string" },
885
949
  surfaces: { type: "string" },
886
950
  parts: { type: "string", multiple: true },
887
951
  fills: { type: "string", multiple: true },
@@ -911,7 +975,7 @@ function parsePublishFlags() {
911
975
 
912
976
  // `--parts name=file` (repeatable; `name#key` targets one instance): the new
913
977
  // 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.
978
+ // `--fills part#fill=file` is the same for one fill of a part.
915
979
  function readPartFlags(values, slug, flag = "parts") {
916
980
  if (!values?.length) return undefined;
917
981
  const example =
@@ -971,6 +1035,9 @@ async function publishMock() {
971
1035
  }
972
1036
  // Checked before the session exists so a typo'd path leaves nothing behind on the server.
973
1037
  if (file && file !== "-" && !existsSync(file)) die(`cannot read ${file}`, `ls ${file}`);
1038
+ if (flags.poster !== undefined && !existsSync(flags.poster)) {
1039
+ die(`cannot read ${flags.poster}`, `ls ${flags.poster}`);
1040
+ }
974
1041
  const parts = readPartFlags(flags.parts, slug);
975
1042
  const fills = readPartFlags(flags.fills, slug, "fills");
976
1043
  let surfaces =
@@ -994,14 +1061,19 @@ async function publishMock() {
994
1061
  }
995
1062
  if (!surfaces && !parts && !fills && !flags.fork && flags.flow === undefined)
996
1063
  die(`no html for ${slug}`, `mockpit publish --mock ${slug} --html <file>`);
1064
+ const poster =
1065
+ flags.poster === undefined ? undefined : (await uploadFile(flags.poster, { session })).id;
997
1066
  const body = {
998
1067
  session,
999
1068
  project,
1000
1069
  mock: slug,
1001
1070
  state: flags.state,
1071
+ situation: flags.situation,
1002
1072
  variant: flags.variant,
1003
1073
  title: flags.title,
1004
1074
  kind: flags.kind,
1075
+ area: flags.area,
1076
+ source: flags.source,
1005
1077
  ...(flags.knobs !== undefined && { knobs: readJsonFlag("knobs", flags.knobs) }),
1006
1078
  ...(from !== undefined && { from }),
1007
1079
  ...(flags.fork !== undefined && { fork: flags.fork }),
@@ -1018,6 +1090,7 @@ async function publishMock() {
1018
1090
  ...(surfaces && { surfaces }),
1019
1091
  ...(parts && { parts }),
1020
1092
  ...(fills && { fills }),
1093
+ ...(poster && { poster }),
1021
1094
  };
1022
1095
  const result = await api(
1023
1096
  "/api/mocks",
@@ -1077,12 +1150,20 @@ function watchLines(batch) {
1077
1150
  const lines = [];
1078
1151
  const name = batch.mock ?? "a mock";
1079
1152
  if (batch.handoff) lines.push(`mockpit handoff on ${name}: ${batch.handoff}`);
1080
- if (batch.resolved) {
1153
+ if (batch.closed) {
1081
1154
  const n = batch.dismissed?.length ?? 0;
1082
1155
  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"}`,
1156
+ `mockpit closed ${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
1157
  );
1085
1158
  }
1159
+ if (batch.edited) {
1160
+ const e = batch.edited;
1161
+ const bits = [
1162
+ e.kind ? `now a ${e.kind}` : "",
1163
+ e.area !== undefined ? (e.area ? `area ${e.area}` : "no area") : "",
1164
+ ].filter(Boolean);
1165
+ lines.push(`mockpit edited ${name}: the user moved it (${bits.join(", ")})`);
1166
+ }
1086
1167
  if (batch.reply) {
1087
1168
  const r = batch.reply;
1088
1169
  const bits = [
@@ -1095,21 +1176,30 @@ function watchLines(batch) {
1095
1176
  .join("; ")
1096
1177
  : "",
1097
1178
  Object.keys(r.tuned ?? {}).length ? `${Object.keys(r.tuned).length} tuned` : "",
1098
- Object.keys(r.mix ?? {}).length
1099
- ? `mix ${Object.entries(r.mix)
1179
+ Object.keys(r.borrowed ?? {}).length
1180
+ ? `borrowed ${Object.entries(r.borrowed)
1100
1181
  .map(([p, v]) => `${p}←${v}`)
1101
1182
  .join(", ")}`
1102
1183
  : "",
1103
- r.comments?.length ? `${r.comments.length} comment${r.comments.length === 1 ? "" : "s"}` : "",
1184
+ ...(r.comments ?? [])
1185
+ .filter((c) => c.borrow)
1186
+ .map(
1187
+ (c) =>
1188
+ `borrow ${c.borrow.part ? `${c.borrow.part} ` : ""}from ${c.borrow.from} into ${c.variant}@${c.version}${c.text ? ` (“${c.text}”)` : ""}`,
1189
+ ),
1190
+ (() => {
1191
+ const n = (r.comments ?? []).filter((c) => !c.borrow).length;
1192
+ return n ? `${n} comment${n === 1 ? "" : "s"}` : "";
1193
+ })(),
1104
1194
  r.reactions?.length ? r.reactions.join(", ") : "",
1105
- r.decision ? r.decision.kind : "",
1195
+ r.verdict ? `${r.verdict.kind} ${r.verdict.variant}` : "",
1106
1196
  r.text ?? "",
1107
1197
  ].filter(Boolean);
1108
1198
  lines.push(`mockpit reply on ${name}: ${bits.join(" · ").replace(/\s+/g, " ")}`);
1109
1199
  }
1110
- for (const c of batch.comments ?? []) {
1200
+ for (const c of batch.messages ?? []) {
1111
1201
  lines.push(
1112
- `mockpit comment on ${name}: “${String(c.text ?? "")
1202
+ `mockpit message on ${name}: “${String(c.text ?? "")
1113
1203
  .replace(/\s+/g, " ")
1114
1204
  .trim()}”`,
1115
1205
  );
@@ -1132,8 +1222,13 @@ async function apiSoft(path) {
1132
1222
  return res.json().catch(() => null);
1133
1223
  }
1134
1224
 
1135
- // Keep the scratch directory out of the repo. Returns true when .gitignore was
1136
- // touched, so init can report it.
1225
+ // Keep the scratch directory out of the repo, all but the project's rules
1226
+ // file, which is meant to be committed. Git can't re-include a file under an
1227
+ // ignored directory, so the ignore has to name the directory's contents, and
1228
+ // the `.mockpit/` line older versions wrote is upgraded in place. Returns true
1229
+ // when .gitignore was touched, so init can report it.
1230
+ const MOCKPIT_IGNORE = ".mockpit/*\n!.mockpit/rules.md";
1231
+
1137
1232
  function ignoreMockpitDir() {
1138
1233
  const file = join(process.cwd(), ".gitignore");
1139
1234
  let current = "";
@@ -1142,14 +1237,56 @@ function ignoreMockpitDir() {
1142
1237
  } catch {
1143
1238
  // no .gitignore yet — create one
1144
1239
  }
1145
- if (/^\.mockpit\/?$/m.test(current)) return false;
1240
+ if (/^\.mockpit\/\*$/m.test(current) && /^!\.mockpit\/rules\.md$/m.test(current)) return false;
1241
+ if (/^\.mockpit\/?$/m.test(current)) {
1242
+ writeFileSync(file, current.replace(/^\.mockpit\/?$/m, MOCKPIT_IGNORE));
1243
+ return true;
1244
+ }
1245
+ const lines = /^\.mockpit\/\*$/m.test(current) ? "!.mockpit/rules.md" : MOCKPIT_IGNORE;
1146
1246
  writeFileSync(
1147
1247
  file,
1148
- current && !current.endsWith("\n") ? `${current}\n.mockpit/\n` : `${current}.mockpit/\n`,
1248
+ current && !current.endsWith("\n") ? `${current}\n${lines}\n` : `${current}${lines}\n`,
1149
1249
  );
1150
1250
  return true;
1151
1251
  }
1152
1252
 
1253
+ function repoRoot() {
1254
+ try {
1255
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], {
1256
+ encoding: "utf8",
1257
+ stdio: ["ignore", "pipe", "ignore"],
1258
+ }).trim();
1259
+ if (top) return top;
1260
+ } catch {}
1261
+ return process.cwd();
1262
+ }
1263
+
1264
+ // Where `mockpit serve` reads the user's own rules: next to its database.
1265
+ const myRulesFile = () =>
1266
+ join(dirname(process.env.MOCKPIT_DB ?? join(homedir(), ".mockpit", "mockpit.db")), "rules.md");
1267
+
1268
+ // Sends a rules file as it is on disk: a missing file clears the stored copy,
1269
+ // so deleting the file and re-running is how rules are switched off.
1270
+ async function sendRules(file, path) {
1271
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
1272
+ const result = await api(
1273
+ path,
1274
+ { method: "PUT", body: JSON.stringify({ text }) },
1275
+ { report: "die", fix: "mockpit rules --help" },
1276
+ );
1277
+ return { ...result, missing: !text };
1278
+ }
1279
+
1280
+ function rulesSummary(r) {
1281
+ if (r.cleared) return "none (cleared)";
1282
+ const bits = [
1283
+ r.packs?.length ? `packs ${r.packs.join(", ")}` : null,
1284
+ r.rules ? "own rules" : null,
1285
+ ].filter(Boolean);
1286
+ const unknown = r.unknown?.length ? ` · unknown pack ${r.unknown.join(", ")}` : "";
1287
+ return `${bits.join(" · ") || "empty"}${unknown}`;
1288
+ }
1289
+
1153
1290
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1154
1291
 
1155
1292
  // Semver greater-than for plain x.y.z (mirrors server/app.ts versionGt).
@@ -1396,7 +1533,11 @@ const commands = {
1396
1533
  mkdirSync(dirname(starter), { recursive: true });
1397
1534
  writeFileSync(starter, renderStarter({ ...design, kit }, iconSets));
1398
1535
  say("wrote:", ".mockpit/starter.html");
1399
- if (ignoreMockpitDir()) say("wrote:", ".gitignore (+ .mockpit/)");
1536
+ if (ignoreMockpitDir()) say("wrote:", ".gitignore (+ .mockpit/, keeping .mockpit/rules.md)");
1537
+ const rulesFile = join(repoRoot(), ".mockpit", "rules.md");
1538
+ if (existsSync(rulesFile)) {
1539
+ say("rules:", rulesSummary(await sendRules(rulesFile, `${projectPath(project)}/rules`)));
1540
+ }
1400
1541
  say("next:", "mockpit guide");
1401
1542
  if (flags.json) out({ project, design: stored, starter });
1402
1543
  },
@@ -1413,6 +1554,7 @@ const commands = {
1413
1554
  project: { type: "string" },
1414
1555
  session: { type: "string" },
1415
1556
  option: { type: "string", multiple: true },
1557
+ recommend: { type: "string" },
1416
1558
  scope: { type: "string" },
1417
1559
  state: { type: "string" },
1418
1560
  part: { type: "string" },
@@ -1422,7 +1564,10 @@ const commands = {
1422
1564
  asks: { type: "string" },
1423
1565
  },
1424
1566
  });
1425
- const slug = requireMock(flags, 'mockpit ask --mock writer "Which look?" --option Quiet=quiet');
1567
+ const slug = requireMock(
1568
+ flags,
1569
+ 'mockpit ask --mock writer "Quiet or dark?" --option Quiet=quiet',
1570
+ );
1426
1571
  let asks;
1427
1572
  if (flags.asks !== undefined) {
1428
1573
  if (positionals.length > 0) {
@@ -1438,7 +1583,7 @@ const commands = {
1438
1583
  if (!text)
1439
1584
  die(
1440
1585
  "ask needs a question",
1441
- `mockpit ask --mock ${slug} "Which look?" --option Quiet=quiet`,
1586
+ `mockpit ask --mock ${slug} "Quiet or dark?" --option Quiet=quiet`,
1442
1587
  );
1443
1588
  const options = (flags.option ?? []).map(parseOption);
1444
1589
  if (options.length === 0) {
@@ -1447,6 +1592,17 @@ const commands = {
1447
1592
  `mockpit ask --mock ${slug} "${text}" --option Quiet=quiet --option Dark=dark`,
1448
1593
  );
1449
1594
  }
1595
+ if (flags.recommend !== undefined) {
1596
+ const pick = options.find(
1597
+ (o) => o.label.toLowerCase() === flags.recommend.trim().toLowerCase(),
1598
+ );
1599
+ if (!pick)
1600
+ die(
1601
+ `--recommend "${flags.recommend}" names no option`,
1602
+ `mockpit ask --mock ${slug} "${text}" --option Quiet=quiet --option Dark=dark --recommend Quiet`,
1603
+ );
1604
+ pick.recommended = true;
1605
+ }
1450
1606
  asks = [
1451
1607
  {
1452
1608
  text,
@@ -1529,13 +1685,13 @@ const commands = {
1529
1685
  console.log(
1530
1686
  `${project} · ${mocks.length} mock${mocks.length === 1 ? "" : "s"}` +
1531
1687
  (list.open
1532
- ? ` · ${list.open} open ask${list.open === 1 ? "" : "s"}`
1688
+ ? ` · ${list.open} open question${list.open === 1 ? "" : "s"}`
1533
1689
  : " · nothing waiting on the user"),
1534
1690
  );
1535
1691
  for (const m of mocks) {
1536
1692
  const states = m.states.length ? m.states.join(" / ") : "single state";
1537
1693
  console.log(
1538
- ` ${m.slug} · ${m.kind} · ${states} · ${m.variants} variant${m.variants === 1 ? "" : "s"}${m.resolvedAt ? " · resolved" : m.open ? ` · ${m.open} open` : ""}`,
1694
+ ` ${m.slug} · ${m.kind}${m.area ? ` · ${m.area}` : ""} · ${states} · ${m.variants} variant${m.variants === 1 ? "" : "s"}${m.closedAt ? " · closed" : m.open ? ` · ${m.open} open` : ""}`,
1539
1695
  );
1540
1696
  }
1541
1697
  for (const flow of list.flows ?? []) console.log(` ${flowLine(flow)}`);
@@ -1564,8 +1720,8 @@ const commands = {
1564
1720
  const dir = join(root, entry.state === null ? "default" : slugify(entry.state));
1565
1721
  mkdirSync(dir, { recursive: true });
1566
1722
  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);
1723
+ // The state with every fill, when index.html is the composed choice.
1724
+ if (entry.allFills) writeFileSync(join(dir, "all-fills.html"), entry.allFills);
1569
1725
  const decided = data.decisions?.states?.[entry.state ?? ""];
1570
1726
  if (decided) {
1571
1727
  writeFileSync(
@@ -1660,9 +1816,13 @@ const commands = {
1660
1816
  // feedback is delivered exactly once across watch, feedback, and piggyback.
1661
1817
  let firstAfter = flags.after;
1662
1818
  for (;;) {
1663
- const session = (await resolveSession(flags)) ?? (await resolveSessionByCwd());
1819
+ const key = harnessKey();
1820
+ const session =
1821
+ (await resolveSession(flags)) ??
1822
+ (key ? await resolveSessionByKey(key) : await resolveSessionByCwd());
1664
1823
  if (!session) {
1665
- await sleep(2000);
1824
+ // A conversation that hasn't used mockpit yet may never; ask rarely.
1825
+ await sleep(key ? 10_000 : 2000);
1666
1826
  continue;
1667
1827
  }
1668
1828
  let result;
@@ -1712,6 +1872,102 @@ const commands = {
1712
1872
  console.log(`said on ${slug}`);
1713
1873
  printFeedback(result.feedback);
1714
1874
  },
1875
+ async edit() {
1876
+ const { values: flags } = parse({
1877
+ options: {
1878
+ mock: { type: "string" },
1879
+ project: { type: "string" },
1880
+ session: { type: "string" },
1881
+ slug: { type: "string" },
1882
+ title: { type: "string" },
1883
+ kind: { type: "string" },
1884
+ area: { type: "string" },
1885
+ source: { type: "string" },
1886
+ rename: { type: "string" },
1887
+ "area-order": { type: "string" },
1888
+ },
1889
+ });
1890
+ const project = resolveProject(flags).name;
1891
+ const session = (await resolveSession(flags)) ?? undefined;
1892
+ const areaOrder = flags["area-order"];
1893
+ if (flags.rename !== undefined || areaOrder !== undefined) {
1894
+ if (flags.mock) {
1895
+ die(
1896
+ "--rename and --area-order edit the project; edit the mock without them",
1897
+ "mockpit edit --help",
1898
+ );
1899
+ }
1900
+ if (flags.rename !== undefined && !flags.rename.trim()) {
1901
+ die("--rename needs the new name", `mockpit edit --rename <name>`);
1902
+ }
1903
+ const result = await api(
1904
+ projectPath(project),
1905
+ {
1906
+ method: "PATCH",
1907
+ body: JSON.stringify({
1908
+ name: flags.rename,
1909
+ ...(areaOrder === undefined ? {} : { areaOrder: areaOrder.split(",") }),
1910
+ session,
1911
+ }),
1912
+ },
1913
+ { report: "die", fix: "mockpit read" },
1914
+ );
1915
+ if (flags.json) return out(result);
1916
+ if (flags.quiet) return;
1917
+ if (result.areaOrder) {
1918
+ console.log(
1919
+ `area order ${result.areaOrder.length ? result.areaOrder.join(", ") : "alphabetical"} · ${result.url}`,
1920
+ );
1921
+ }
1922
+ for (const nudge of result.nudges ?? []) console.log(`nudge: ${nudge}`);
1923
+ if (!result.renamed) return printFeedback(result.feedback);
1924
+ console.log(
1925
+ `renamed project ${result.renamed.from} → ${result.renamed.to} · ${result.mocks} mock${result.mocks === 1 ? "" : "s"} · ${result.url}`,
1926
+ );
1927
+ if (!flags.project && !process.env.MOCKPIT_PROJECT) {
1928
+ console.log(
1929
+ `note: this repo still resolves to "${project}"; pass --project ${shq(result.project)} or set MOCKPIT_PROJECT`,
1930
+ );
1931
+ }
1932
+ printFeedback(result.feedback);
1933
+ return;
1934
+ }
1935
+ const slug = requireMock(flags, "mockpit edit --mock writer --area Shell");
1936
+ const fields = ["slug", "title", "kind", "area", "source"].filter(
1937
+ (k) => flags[k] !== undefined,
1938
+ );
1939
+ if (!fields.length) {
1940
+ die("edit needs --slug, --title, --kind, --area or --source", "mockpit edit --help");
1941
+ }
1942
+ const result = await api(
1943
+ mockPath(slug),
1944
+ {
1945
+ method: "PATCH",
1946
+ body: JSON.stringify({
1947
+ project,
1948
+ session,
1949
+ ...Object.fromEntries(fields.map((k) => [k, flags[k]])),
1950
+ }),
1951
+ },
1952
+ { report: "die", fix: `mockpit read ${slug}` },
1953
+ );
1954
+ if (flags.json) return out(result);
1955
+ if (flags.quiet) return;
1956
+ const m = result.mock;
1957
+ console.log(
1958
+ [
1959
+ result.renamed ? `${result.renamed.from} → ${m.slug}` : m.slug,
1960
+ m.kind,
1961
+ m.area && `area ${m.area}`,
1962
+ m.source && `source ${m.source}`,
1963
+ result.url,
1964
+ ]
1965
+ .filter(Boolean)
1966
+ .join(" · "),
1967
+ );
1968
+ for (const nudge of result.nudges ?? []) console.log(`nudge: ${nudge}`);
1969
+ printFeedback(result.feedback);
1970
+ },
1715
1971
  async demo() {
1716
1972
  parse();
1717
1973
  const { DEMO } = await import("./demoData.js");
@@ -1729,6 +1985,7 @@ const commands = {
1729
1985
  session: session.id,
1730
1986
  project: DEMO.project,
1731
1987
  mock: DEMO.slug,
1988
+ kind: "page",
1732
1989
  title: DEMO.title,
1733
1990
  state: state.label,
1734
1991
  variant: variant.name,
@@ -1860,6 +2117,24 @@ const commands = {
1860
2117
  return printBrief(flags);
1861
2118
  },
1862
2119
 
2120
+ // A rules file is the user's or the project's own guidance on top of the
2121
+ // core brief; the brief prints it, so this only has to put it on the server.
2122
+ async rules() {
2123
+ const { values: flags } = parse({
2124
+ options: { mine: { type: "boolean" }, project: { type: "string" } },
2125
+ });
2126
+ const mine = !!flags.mine;
2127
+ const file = mine ? myRulesFile() : join(repoRoot(), ".mockpit", "rules.md");
2128
+ const project = mine ? null : resolveProject(flags).name;
2129
+ const result = await sendRules(file, mine ? "/api/rules" : `${projectPath(project)}/rules`);
2130
+ if (flags.json) return out(result);
2131
+ if (flags.quiet) return;
2132
+ const where = mine ? "your rules" : `project ${project}`;
2133
+ const from = result.missing ? `no ${file}` : file;
2134
+ console.log(`${where}: ${rulesSummary(result)} (from ${from})`);
2135
+ if (!result.missing) console.log("next:", "mockpit guide");
2136
+ },
2137
+
1863
2138
  async setup() {
1864
2139
  parse();
1865
2140
  console.log(await fetchTextWithFallback("/setup", join(ROOT, "guide", "AGENT_SETUP.md")));