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
@@ -1,5 +1,6 @@
1
1
  import { type Decisions } from "./decisions.ts";
2
- import { type AskOption, type Comment, type CommentAnchor, type Mock, OTHER_ID, type PartComment, type Post, type Reaction, type Reply, type Store } from "./types.ts";
2
+ import { type StandingView } from "./standings.ts";
3
+ import { type AskOption, type Comment, type CommentAnchor, type Design, type DesignEdit, OTHER_ID, type PartComment, type Post, type Reaction, type Reply, type Store } from "./types.ts";
3
4
  export interface VariantRef {
4
5
  state: string | null;
5
6
  variant: string;
@@ -7,7 +8,7 @@ export interface VariantRef {
7
8
  export interface InUseRef extends VariantRef {
8
9
  version: number;
9
10
  }
10
- export interface FeedbackComment {
11
+ export interface FeedbackMessage {
11
12
  seq: number;
12
13
  text: string;
13
14
  state: string | null;
@@ -27,14 +28,40 @@ export interface OtherChoice {
27
28
  other: true;
28
29
  images?: ImageRef[];
29
30
  }
30
- export interface AnsweredAsk {
31
+ export interface AnsweredAsk extends StandingView {
31
32
  ask: string;
32
33
  text: string;
33
34
  chosen: (AskOption | OtherChoice)[];
35
+ comment?: string;
36
+ commentImages?: ImageRef[];
37
+ }
38
+ export interface MoreAnswers {
39
+ ask: string;
40
+ text: string;
41
+ existing: string[];
34
42
  note?: string;
35
- noteImages?: ImageRef[];
43
+ like?: string;
44
+ kept?: string[];
45
+ hidden?: {
46
+ label: string;
47
+ why?: string;
48
+ }[];
49
+ answered: {
50
+ ask: string;
51
+ text: string;
52
+ chosen: string[];
53
+ comment?: string;
54
+ }[];
36
55
  }
37
- export interface FeedbackReply extends Omit<Reply, "others" | "notes" | "otherImages" | "noteImages"> {
56
+ export interface UpdateRequest {
57
+ ask: string;
58
+ text: string;
59
+ kept: AskOption;
60
+ inUse: AskOption[];
61
+ }
62
+ export interface FeedbackReply extends Omit<Reply, "others" | "askComments" | "otherImages" | "askCommentImages" | "more" | "standings" | "update"> {
63
+ more?: MoreAnswers;
64
+ update?: UpdateRequest;
38
65
  seq: number;
39
66
  at: string;
40
67
  asks: AnsweredAsk[];
@@ -42,25 +69,20 @@ export interface FeedbackReply extends Omit<Reply, "others" | "notes" | "otherIm
42
69
  decisions?: Decisions;
43
70
  }
44
71
  export interface FeedbackBatch {
45
- mockId: string | null;
72
+ designId: string | null;
46
73
  project: string | null;
47
- mock: string | null;
74
+ design: string | null;
48
75
  title: string | null;
49
76
  reply: FeedbackReply | null;
50
- comments: FeedbackComment[];
51
- accepted: InUseRef[];
77
+ messages: FeedbackMessage[];
78
+ inUse: InUseRef[];
52
79
  archived: VariantRef[];
53
- dropped: VariantRef[];
54
80
  handoff?: string;
55
- resolved?: true;
56
- dismissed?: {
57
- ask: string;
58
- text: string;
59
- }[];
81
+ edited?: DesignEdit;
60
82
  }
61
83
  export declare function reactionLine(c: PartComment & {
62
84
  reaction: Reaction;
63
85
  }): string;
64
- export declare function groupFeedback(comments: Comment[], mocks: Map<string, Mock>, posts: Map<string, Post[]>, base?: string): FeedbackBatch[];
86
+ export declare function groupFeedback(comments: Comment[], designs: Map<string, Design>, posts: Map<string, Post[]>, base?: string): FeedbackBatch[];
65
87
  export declare function buildFeedbackBatches(store: Store, comments: Comment[], base: string): Promise<FeedbackBatch[]>;
66
88
  export declare function feedbackImages(batches: FeedbackBatch[]): ImageRef[];
@@ -1,10 +1,60 @@
1
- // The agent-facing shape of feedback: one batch per mock, in delivery order.
1
+ // The agent-facing shape of feedback: one batch per design, in delivery order.
2
2
  // The `feedback` verb, the watch long-poll and the write piggyback all return this,
3
- // so an agent reads one grouped object instead of re-deriving which mock a flat
3
+ // so an agent reads one grouped object instead of re-deriving which design a flat
4
4
  // comment list belongs to. Runtime-agnostic (no node imports).
5
5
  import { decisionsFor } from "./decisions.js";
6
+ import { ROUTED_NOTE } from "./handoff.js";
7
+ import { standingOf, standingView } from "./standings.js";
6
8
  import { builtinAsk, inUseVersion, DEFAULT_VARIANT, OTHER_ID, } from "./types.js";
7
9
  const ref = (p) => ({ state: p.state, variant: p.variant });
10
+ function moreAnswers(design, more, sent) {
11
+ const ask = design.asks.find((a) => a.id === more.ask);
12
+ if (!ask)
13
+ return undefined;
14
+ const st = sent ?? standingOf(design, ask.id);
15
+ const view = standingView(ask, st);
16
+ const like = more.like ? ask.options.find((o) => o.id === more.like)?.label : undefined;
17
+ const labels = (a) => {
18
+ const ids = a.answer === undefined ? [] : Array.isArray(a.answer) ? a.answer : [a.answer];
19
+ return [
20
+ ...ids.map((id) => a.options.find((o) => o.id === id)?.label ?? id),
21
+ ...(a.other === undefined ? [] : [a.other]),
22
+ ];
23
+ };
24
+ return {
25
+ ask: ask.id,
26
+ text: ask.text,
27
+ // Hidden ones are out: asked again, they'd only fold away.
28
+ existing: ask.options.filter((o) => !st.hidden.some((h) => h.id === o.id)).map((o) => o.label),
29
+ ...(more.note ? { note: more.note } : {}),
30
+ ...(like ? { like } : {}),
31
+ ...(view?.kept ? { kept: view.kept.map((k) => k.label) } : {}),
32
+ ...(view?.hidden
33
+ ? { hidden: view.hidden.map((h) => ({ label: h.label, ...(h.why ? { why: h.why } : {}) })) }
34
+ : {}),
35
+ answered: design.asks
36
+ .filter((a) => a.id !== ask.id && labels(a).length > 0)
37
+ .map((a) => ({
38
+ ask: a.id,
39
+ text: a.text,
40
+ chosen: labels(a),
41
+ ...(a.comment ? { comment: a.comment } : {}),
42
+ })),
43
+ };
44
+ }
45
+ function updateRequest(design, wanted) {
46
+ const ask = design.asks.find((a) => a.id === wanted.ask);
47
+ const kept = ask?.options.find((o) => o.id === wanted.option);
48
+ if (!ask || !kept)
49
+ return undefined;
50
+ const ids = ask.answer === undefined ? [] : [ask.answer].flat();
51
+ return {
52
+ ask: ask.id,
53
+ text: ask.text,
54
+ kept,
55
+ inUse: ask.options.filter((o) => ids.includes(o.id)),
56
+ };
57
+ }
8
58
  const REACTION_LEAD = {
9
59
  up: "👍",
10
60
  down: "👎",
@@ -12,8 +62,11 @@ const REACTION_LEAD = {
12
62
  };
13
63
  // `👍 part "hero" (variant B, state Mobile)`, `👎 variant B`, `more like this: the page`.
14
64
  export function reactionLine(c) {
15
- // A mock published without variant names has only "default": naming it is noise.
16
- const variant = c.variant && c.variant !== DEFAULT_VARIANT ? c.variant : null;
65
+ // A design published without variant names has only "default": naming it is noise.
66
+ const named = c.variant && c.variant !== DEFAULT_VARIANT ? c.variant : null;
67
+ // A version is written as agents write it, dark@2.
68
+ const at = c.version ? `@${c.version}` : "";
69
+ const variant = named ? `${named}${at}` : at ? `${DEFAULT_VARIANT}${at}` : null;
17
70
  const where = [
18
71
  c.part !== null && variant ? `variant ${variant}` : "",
19
72
  c.state ? `state ${c.state}` : "",
@@ -28,21 +81,32 @@ export function reactionLine(c) {
28
81
  return `${REACTION_LEAD[c.reaction]} ${target}${tail}${note}`;
29
82
  }
30
83
  const isReaction = (c) => !!c.reaction;
31
- function answered(reply, mock, posts, base) {
84
+ function answered(reply, design, posts, base) {
32
85
  const others = reply.others ?? {};
33
- const notes = reply.notes ?? {};
86
+ const comments = reply.askComments ?? {};
34
87
  const refs = (ids) => (ids ?? []).map((id) => ({ id, url: `${base}/a/${encodeURIComponent(id)}` }));
88
+ const standings = reply.standings ?? {};
35
89
  const askIds = new Set([
36
90
  ...Object.keys(reply.answers),
37
91
  ...Object.keys(others),
38
- ...Object.keys(notes),
92
+ ...Object.keys(comments),
93
+ // A question whose answers the user only kept or hid is still news: more answers
94
+ // should follow the kept ones and avoid the hidden.
95
+ ...Object.keys(standings).filter((id) => id !== reply.more?.ask),
39
96
  ]);
40
97
  const out = [];
41
98
  for (const askId of askIds) {
42
- const ask = mock?.asks.find((a) => a.id === askId) ?? (mock && builtinAsk(mock, posts, askId));
99
+ const ask = design?.asks.find((a) => a.id === askId) ?? (design && builtinAsk(design, posts, askId));
43
100
  const answer = reply.answers[askId];
44
101
  const ids = answer === undefined ? [] : Array.isArray(answer) ? answer : [answer];
45
- const chosen = ids.map((id) => ask?.options.find((o) => o.id === id) ?? { id, label: id });
102
+ const chosen = ids.map((id) => {
103
+ const option = ask?.options.find((o) => o.id === id);
104
+ if (!option)
105
+ return { id, label: id };
106
+ // What it brought isn't in any version yet: an agent folding it in needs it here.
107
+ const brought = design?.answerAdditions.find((a) => Object.entries(option.set ?? {}).some(([k, v]) => a.knob === k && a.value === v));
108
+ return brought ? { ...option, brought } : option;
109
+ });
46
110
  const otherImages = refs(reply.otherImages?.[askId]);
47
111
  if (others[askId] !== undefined)
48
112
  chosen.push({
@@ -51,77 +115,84 @@ function answered(reply, mock, posts, base) {
51
115
  other: true,
52
116
  ...(otherImages.length ? { images: otherImages } : {}),
53
117
  });
54
- const noteImages = refs(reply.noteImages?.[askId]);
118
+ const commentImages = refs(reply.askCommentImages?.[askId]);
119
+ const st = standings[askId];
55
120
  out.push({
56
121
  ask: askId,
57
122
  text: ask?.text ?? "",
58
123
  chosen,
59
- ...(notes[askId] !== undefined ? { note: notes[askId] } : {}),
60
- ...(noteImages.length ? { noteImages } : {}),
124
+ ...(ask && st ? standingView({ ...ask, answer }, st) : {}),
125
+ ...(comments[askId] !== undefined ? { comment: comments[askId] } : {}),
126
+ ...(commentImages.length ? { commentImages } : {}),
61
127
  });
62
128
  }
63
129
  return out;
64
130
  }
65
131
  // Comments arrive in seq order; keep that order both between batches and inside
66
- // one. A mock gets a fresh batch whenever a second reply arrives for it, so a
132
+ // one. A design gets a fresh batch whenever a second reply arrives for it, so a
67
133
  // batch carries at most one reply and nothing is merged away. A comment whose
68
- // mock is unknown still gets a batch — feedback is never dropped for want of
134
+ // design is unknown still gets a batch — feedback is never dropped for want of
69
135
  // context. `base` (origin + base path) makes image URLs absolute.
70
- export function groupFeedback(comments, mocks, posts, base = "") {
136
+ export function groupFeedback(comments, designs, posts, base = "") {
71
137
  const batches = [];
72
138
  const open = new Map();
73
139
  for (const c of comments) {
74
- const key = c.mockId ?? "";
75
- const mock = c.mockId ? mocks.get(c.mockId) : undefined;
76
- const mockPosts = (c.mockId && posts.get(c.mockId)) || [];
140
+ // Stored from when a close wrote to this stream. Ending something tells the agent
141
+ // nothing, so one still ahead of the cursor stays unsaid.
142
+ if (c.kind === "reply" && c.payload?.archived)
143
+ continue;
144
+ const key = c.designId ?? "";
145
+ const design = c.designId ? designs.get(c.designId) : undefined;
146
+ const designPosts = (c.designId && posts.get(c.designId)) || [];
77
147
  let batch = open.get(key);
78
- if (!batch || (c.kind === "reply" && (batch.reply || batch.resolved))) {
148
+ // An edit says where the design is now, so it joins whatever batch is open.
149
+ const edit = c.kind === "reply" ? c.payload?.edited : undefined;
150
+ if (!batch || (c.kind === "reply" && !edit && batch.reply)) {
79
151
  batch = {
80
- mockId: c.mockId,
81
- project: mock?.project ?? null,
82
- mock: mock?.slug ?? null,
83
- title: mock?.title ?? null,
152
+ designId: c.designId,
153
+ project: design?.project ?? null,
154
+ design: design?.slug ?? null,
155
+ title: design?.title ?? null,
84
156
  reply: null,
85
- comments: [],
86
- accepted: [],
157
+ messages: [],
158
+ inUse: [],
87
159
  archived: [],
88
- dropped: [],
89
160
  };
90
161
  open.set(key, batch);
91
162
  batches.push(batch);
92
163
  }
93
- if (c.kind === "reply" && c.payload?.resolved) {
94
- batch.resolved = true;
95
- batch.dismissed = c.payload.resolved.dismissed.map((id) => ({
96
- ask: id,
97
- text: (mock?.asks.find((a) => a.id === id) ?? (mock && builtinAsk(mock, mockPosts, id)))
98
- ?.text ?? "",
99
- }));
164
+ if (edit) {
165
+ batch.edited = { ...batch.edited, ...edit };
100
166
  continue;
101
167
  }
102
168
  if (c.kind === "reply" && c.payload) {
103
- const { others: _others, notes: _notes, otherImages: _otherImages, noteImages: _noteImages, ...payload } = c.payload;
169
+ const { others: _others, askComments: _askComments, otherImages: _otherImages, askCommentImages: _askCommentImages, routed, more: asked, standings: _standings, update: wanted, ...payload } = c.payload;
170
+ const more = asked && design ? moreAnswers(design, asked, c.payload.standings?.[asked.ask]) : undefined;
171
+ const update = wanted && design ? updateRequest(design, wanted) : undefined;
172
+ if (routed)
173
+ batch.handoff = ROUTED_NOTE;
104
174
  const reactions = (payload.comments ?? []).filter(isReaction).map(reactionLine);
105
175
  batch.reply = {
106
176
  ...payload,
107
177
  comments: (payload.comments ?? []).filter((x) => !x.reaction),
108
178
  seq: c.seq,
109
179
  at: c.createdAt,
110
- asks: answered(c.payload, mock, mockPosts, base),
180
+ asks: answered(c.payload, design, designPosts, base),
111
181
  ...(reactions.length ? { reactions } : {}),
182
+ ...(more ? { more } : {}),
183
+ ...(update ? { update } : {}),
112
184
  };
113
- const decisions = mock ? decisionsFor(mock, mockPosts, c.payload) : null;
185
+ const decisions = design ? decisionsFor(design, designPosts, c.payload) : null;
114
186
  if (decisions)
115
187
  batch.reply.decisions = decisions;
116
- batch.accepted = mockPosts
188
+ batch.inUse = designPosts
117
189
  .filter((p) => p.status === "accepted")
118
190
  .map((p) => ({ ...ref(p), version: inUseVersion(p) }));
119
- batch.archived = mockPosts.filter((p) => p.status === "archived").map(ref);
120
- batch.dropped = mockPosts.filter((p) => p.status === "dropped").map(ref);
191
+ batch.archived = designPosts.filter((p) => p.status === "archived").map(ref);
121
192
  continue;
122
193
  }
123
- const post = c.postId ? mockPosts.find((p) => p.id === c.postId) : undefined;
124
- batch.comments.push({
194
+ const post = c.postId ? designPosts.find((p) => p.id === c.postId) : undefined;
195
+ batch.messages.push({
125
196
  seq: c.seq,
126
197
  text: c.text,
127
198
  state: post?.state ?? null,
@@ -135,18 +206,18 @@ export function groupFeedback(comments, mocks, posts, base = "") {
135
206
  return batches;
136
207
  }
137
208
  export async function buildFeedbackBatches(store, comments, base) {
138
- const mocks = new Map();
209
+ const designs = new Map();
139
210
  const posts = new Map();
140
211
  for (const c of comments) {
141
- if (!c.mockId || mocks.has(c.mockId))
212
+ if (!c.designId || designs.has(c.designId))
142
213
  continue;
143
- const mock = await store.getMock(c.mockId);
144
- if (!mock)
214
+ const design = await store.getDesign(c.designId);
215
+ if (!design)
145
216
  continue;
146
- mocks.set(c.mockId, mock);
147
- posts.set(c.mockId, await store.listPosts({ mockId: c.mockId }));
217
+ designs.set(c.designId, design);
218
+ posts.set(c.designId, await store.listPosts({ designId: c.designId }));
148
219
  }
149
- return groupFeedback(comments, mocks, posts, base);
220
+ return groupFeedback(comments, designs, posts, base);
150
221
  }
151
222
  // Every image a list of batches carries, in order — what the MCP tiers attach
152
223
  // as image content so the model sees what the user attached.
@@ -157,8 +228,8 @@ export function feedbackImages(batches) {
157
228
  for (const c of a.chosen)
158
229
  if ("other" in c && c.images)
159
230
  out.push(...c.images);
160
- if (a.noteImages)
161
- out.push(...a.noteImages);
231
+ if (a.commentImages)
232
+ out.push(...a.commentImages);
162
233
  }
163
234
  }
164
235
  return out;
@@ -26,9 +26,13 @@ export interface FillScan {
26
26
  }
27
27
  export declare function scanFills(html: string): FillScan;
28
28
  export declare function slotKnobs(slots: SlotInfo[]): Knobs;
29
+ export declare const cssString: (s: string) => string;
29
30
  export declare function slotCss(slots: SlotInfo[]): string;
30
31
  export declare function composeFills(html: string, chosen: Record<string, string>): string;
31
32
  export declare function slotsInSurfaces(surfaces: Surface[]): SlotInfo[];
33
+ export declare const AS_IS_FILL = "as-is";
34
+ export declare function partOccurrences(html: string, part: string): number;
35
+ export declare function addFill(html: string, part: string, fill: string, markup: string): string;
32
36
  export type FillSplice = {
33
37
  ok: true;
34
38
  surfaces: Surface[];
@@ -165,7 +165,7 @@ export function slotKnobs(slots) {
165
165
  }
166
166
  return out;
167
167
  }
168
- const cssString = (s) => `"${s.replace(/[\\"]/g, (c) => `\\${c}`).replace(/[\n\r<>]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `)}"`;
168
+ export const cssString = (s) => `"${s.replace(/[\\"]/g, (c) => `\\${c}`).replace(/[\n\r<>]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `)}"`;
169
169
  // Shows only the chosen fill of each slot, keyed on the `data-k-<part>`
170
170
  // attribute the knob runtime keeps on <html>. The chosen fill is never styled:
171
171
  // only the others are hidden, so a fill keeps whatever display the agent gave it.
@@ -254,6 +254,60 @@ function markFill(html, part, fill) {
254
254
  const at = m.index + m[0].indexOf("<") + 1 + m[1].length;
255
255
  return html.slice(0, at) + add + html.slice(at);
256
256
  }
257
+ // The name a plain part's own markup goes by once an answer adds a fill to it.
258
+ export const AS_IS_FILL = "as-is";
259
+ // How many elements declare this part, fills counted once each. A fill can only be
260
+ // added to a part that is one thing or already has fills.
261
+ export function partOccurrences(html, part) {
262
+ if (scanAll(html).ranges.some((r) => r.part === part))
263
+ return 1;
264
+ let n = 0;
265
+ walkOpenTags(html, (attrs) => {
266
+ if (attrs["data-part"] === part)
267
+ n++;
268
+ });
269
+ return n;
270
+ }
271
+ function walkOpenTags(html, visit) {
272
+ TOKEN.lastIndex = 0;
273
+ let m;
274
+ while ((m = TOKEN.exec(html))) {
275
+ if (m[0].startsWith("<!--") || m[1])
276
+ continue;
277
+ const tag = m[2].toLowerCase();
278
+ const end = TOKEN.lastIndex;
279
+ visit(attributes(m[3] ?? ""), m.index + 1 + m[2].length);
280
+ TOKEN.lastIndex = end;
281
+ if (RAW.has(tag)) {
282
+ const endTag = new RegExp(`</${tag}\\s*>`, "gi");
283
+ endTag.lastIndex = end;
284
+ const e = endTag.exec(html);
285
+ TOKEN.lastIndex = e ? endTag.lastIndex : html.length;
286
+ }
287
+ }
288
+ }
289
+ // Adds a fill at render time, leaving the html as it was when it can't: a version
290
+ // without this part has nothing to fill, and one with its own fill of this name
291
+ // already says what that value means.
292
+ export function addFill(html, part, fill, markup) {
293
+ const ranges = scanAll(html).ranges.filter((r) => r.part === part);
294
+ if (ranges.some((r) => r.fill === fill))
295
+ return html;
296
+ let base = html;
297
+ if (ranges.length === 0) {
298
+ const at = [];
299
+ walkOpenTags(html, (attrs, tagNameEnd) => {
300
+ if (attrs["data-part"] === part)
301
+ at.push(tagNameEnd);
302
+ });
303
+ if (at.length !== 1)
304
+ return html;
305
+ base = `${html.slice(0, at[0])} data-fill="${AS_IS_FILL}" data-fill-default${html.slice(at[0])}`;
306
+ }
307
+ const spliced = spliceFills([{ kind: "html", html: base }], { [`${part}#${fill}`]: markup });
308
+ const surface = spliced.ok ? spliced.surfaces[0] : undefined;
309
+ return surface?.kind === "html" ? surface.html : html;
310
+ }
257
311
  // Replace one fill of a slot, or add it after the slot's last fill, keyed
258
312
  // "part#fill". Like spliceParts, every target is located in the original
259
313
  // markup and overlapping edits are refused.
@@ -0,0 +1,23 @@
1
+ export declare const GUIDE_PACKS: readonly ["design-craft"];
2
+ export type GuidePack = (typeof GUIDE_PACKS)[number];
3
+ export declare const RULES_SETTING = "rules";
4
+ export declare const USER_RULES_SETTING = "userRules";
5
+ export declare const MAX_RULES_CHARS = 6000;
6
+ export interface RulesFile {
7
+ packs: string[];
8
+ body: string;
9
+ }
10
+ export declare function parseRulesFile(text: string): RulesFile;
11
+ export interface GuidanceInput {
12
+ packs: Partial<Record<GuidePack, string>>;
13
+ user: string | null;
14
+ project: string | null;
15
+ }
16
+ /**
17
+ * The guidance line and sections for the brief, or "" when only core is on:
18
+ * a workspace that never wrote a rules file gets the brief it always had.
19
+ */
20
+ export declare function guidanceSections(input: GuidanceInput): {
21
+ line: string;
22
+ body: string;
23
+ };
@@ -0,0 +1,71 @@
1
+ // Guidance layers on top of the core brief: packs that ship with mockpit (off
2
+ // until a rules file names them), the user's own rules and the project's.
3
+ // Higher wins: project, then the user's, then packs, then core. The brief
4
+ // prints them in that order from the bottom up, so the last word an agent
5
+ // reads is the one that wins.
6
+ //
7
+ // Runtime-agnostic (no node imports): the Worker builds the brief too.
8
+ export const GUIDE_PACKS = ["design-craft"];
9
+ const isGuidePack = (id) => GUIDE_PACKS.includes(id);
10
+ // `rules:<project>` sits beside `design:<project>` and moves with a project
11
+ // rename. The user's own rules are not a project, so their key has no colon:
12
+ // a project named "user" can't collide with it.
13
+ export const RULES_SETTING = "rules";
14
+ export const USER_RULES_SETTING = "userRules";
15
+ // A rules file is read whole into every brief, so it stays a page, not a book.
16
+ export const MAX_RULES_CHARS = 6_000;
17
+ // `---\npacks: [design-craft]\n---` (or `packs: a, b`, or a `- a` list), then
18
+ // markdown. Anything else in the frontmatter is ignored rather than refused:
19
+ // the file is the user's, and a stray key must not cost them their rules.
20
+ export function parseRulesFile(text) {
21
+ const src = text.replace(/\r\n?/g, "\n");
22
+ const m = /^---\n([\s\S]*?)\n---[ \t]*(?:\n|$)/.exec(src);
23
+ if (!m)
24
+ return { packs: [], body: src.trim() };
25
+ const packs = [];
26
+ const lines = m[1].split("\n");
27
+ for (let i = 0; i < lines.length; i++) {
28
+ const kv = /^packs\s*:\s*(.*)$/.exec(lines[i].trim());
29
+ if (!kv)
30
+ continue;
31
+ const inline = kv[1].trim().replace(/^\[|\]$/g, "");
32
+ if (inline)
33
+ packs.push(...inline.split(","));
34
+ for (let j = i + 1; j < lines.length && /^\s*-\s+/.test(lines[j]); j++) {
35
+ packs.push(lines[j].replace(/^\s*-\s+/, ""));
36
+ }
37
+ }
38
+ const names = packs.map((p) => p.trim().replace(/^["']|["']$/g, "")).filter(Boolean);
39
+ return { packs: [...new Set(names)], body: src.slice(m[0].length).trim() };
40
+ }
41
+ // The layer files' own `## Rules` / `## Suggestions` sit one level under the
42
+ // layer's heading in the brief.
43
+ const demote = (md) => md.replace(/^(#{1,5}) /gm, "#$1 ");
44
+ /**
45
+ * The guidance line and sections for the brief, or "" when only core is on:
46
+ * a workspace that never wrote a rules file gets the brief it always had.
47
+ */
48
+ export function guidanceSections(input) {
49
+ const user = input.user ? parseRulesFile(input.user) : null;
50
+ const project = input.project ? parseRulesFile(input.project) : null;
51
+ const named = [...new Set([...(user?.packs ?? []), ...(project?.packs ?? [])])];
52
+ const on = named.filter((p) => isGuidePack(p) && !!input.packs[p]);
53
+ const unknown = named.filter((p) => !on.includes(p));
54
+ const layers = [
55
+ ...on.map((p) => ({ label: p, heading: `Pack: ${p}`, text: input.packs[p].trim() })),
56
+ ...(user?.body ? [{ label: "your rules", heading: "Your rules", text: user.body }] : []),
57
+ ...(project?.body
58
+ ? [{ label: "project", heading: "This project's rules", text: project.body }]
59
+ : []),
60
+ ];
61
+ if (layers.length === 0 && unknown.length === 0)
62
+ return { line: "", body: "" };
63
+ const missing = unknown.length
64
+ ? ` (unknown pack ${unknown.map((p) => `"${p}"`).join(", ")}; packs: ${GUIDE_PACKS.join(", ")})`
65
+ : "";
66
+ const line = `Guidance: ${["core", ...layers.map((l) => l.label)].join(" · ")}${missing}. ` +
67
+ "Higher wins: project, then yours, then packs, then core; the user's own words beat " +
68
+ "all of them. **Rules** are always followed; **Suggestions** are aimed for.";
69
+ const body = layers.map((l) => `## ${l.heading}\n\n${demote(l.text)}`).join("\n\n");
70
+ return { line, body };
71
+ }
@@ -1,6 +1,9 @@
1
1
  import type { Comment, Session } from "./types.ts";
2
2
  export declare const HANDOFF_GRACE_MS: number;
3
- export declare const HANDOFF_NOTE = "Sent to an earlier agent session in this project that has gone quiet; it is yours now. Read the mock before acting.";
3
+ export declare const HANDOFF_NOTE = "Sent to an earlier agent session in this project that has gone quiet; it is yours now. Read the design before acting.";
4
+ export declare const ROUTED_NOTE = "The agent that asked this wasn't listening, so the user sent it to you; it is yours now. Read the design before acting.";
5
+ export declare const OWNER_BUSY_MS: number;
4
6
  export declare const ownerActiveAt: (s: Session) => string;
7
+ export declare const sessionName: (s: Session) => string;
5
8
  export declare function supersedes(taker: Session, owner: Session, listening: boolean): boolean;
6
9
  export declare function handoffReady(comment: Comment, owner: Session, taker: Session, now: number): boolean;
@@ -1,4 +1,4 @@
1
- // Orphaned replies: a Send lands on the session that owns the mock, and when
1
+ // Orphaned replies: a Send lands on the session that owns the design, and when
2
2
  // that agent restarts into a new session (a new conversation, /clear, a crash)
3
3
  // nobody would ever read it. The rule below decides when a newer session may
4
4
  // take such a reply instead. Runtime-agnostic (no node imports).
@@ -20,8 +20,14 @@
20
20
  // it got before the taker existed goes at once — the user answered, then
21
21
  // restarted. A keyed owner always gets the grace: its conversation may resume.
22
22
  export const HANDOFF_GRACE_MS = 2 * 60_000;
23
- export const HANDOFF_NOTE = "Sent to an earlier agent session in this project that has gone quiet; it is yours now. Read the mock before acting.";
23
+ export const HANDOFF_NOTE = "Sent to an earlier agent session in this project that has gone quiet; it is yours now. Read the design before acting.";
24
+ export const ROUTED_NOTE = "The agent that asked this wasn't listening, so the user sent it to you; it is yours now. Read the design before acting.";
25
+ // A waiter stops between a wake and the end of that turn, so an owner that called
26
+ // this recently is working, not gone, and keeps its replies. Same span as "Agent working".
27
+ export const OWNER_BUSY_MS = 5 * 60_000;
24
28
  export const ownerActiveAt = (s) => s.agentActiveAt ?? s.lastActiveAt;
29
+ // All the user can recognise a session by: most have no title, and ids mean nothing.
30
+ export const sessionName = (s) => s.cwd?.split(/[\\/]/).filter(Boolean).pop() ?? s.agent;
25
31
  export function supersedes(taker, owner, listening) {
26
32
  return (owner.id !== taker.id &&
27
33
  owner.project !== null &&
@@ -4,7 +4,7 @@
4
4
  // imports): it runs inside renderHtmlPage on the Worker too.
5
5
  // Served with the server rather than installed per project: lucide (ISC) is
6
6
  // what agents reach for by default, mage (Apache-2.0) is what `mockpit init`
7
- // used to upload as a sprite, so mocks written against it keep rendering.
7
+ // used to upload as a sprite, so designs written against it keep rendering.
8
8
  export const BUNDLED_ICON_PREFIXES = ["lucide", "mage"];
9
9
  const NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
10
10
  export const isIconName = (s) => typeof s === "string" && NAME_RE.test(s);