passalong 0.20.0 → 1.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.
package/src/guide.js DELETED
@@ -1,811 +0,0 @@
1
- // The transfer guide format: plain markdown with a small YAML-ish frontmatter block.
2
- //
3
- // No proprietary format and no YAML library. The frontmatter only ever holds strings and
4
- // lists of strings, so a deliberately tiny parser covers it and stays readable when hand-edited.
5
- import { randomBytes } from "node:crypto";
6
-
7
- /**
8
- * A guide is a draft or it is published, and `consumed` is the author's shelf — off the board, out
9
- * of the free tier's count, reversible. `promoted` is the one true legacy: an author lifecycle laid
10
- * over a transfer that already reports itself, since a pull count says how travelled a guide is
11
- * without anyone having to grade it.
12
- *
13
- * This list is what is **accepted**, and it is not what may be **set** — that is `SETTABLE` below.
14
- * The distinction is load-bearing. A status lives in frontmatter, inside markdown files in other
15
- * people's repositories, and `validate()` rejects a status it does not know, so dropping a value
16
- * from here would make a guide shared a month ago fail to re-share today.
17
- */
18
- export const STATUSES = ["draft", "published", "consumed", "promoted"];
19
-
20
- /**
21
- * What a write may choose. `promoted` is absent: it cannot be set on a guide that does not already
22
- * carry it, from any surface.
23
- *
24
- * Two reasons, and the second is the sharper one. A status that can still be set reads as a
25
- * feature to whoever meets it next, however many deprecation notices sit around it. And
26
- * `quota.ts` counts `published` and `promoted` as the statuses that occupy room — so while
27
- * `promoted` remains settable, promoting every guide is an unlimited free tier. Closing the write
28
- * closes that, and does it without touching what the free tier counts, which still has to include
29
- * the guides already carrying the status.
30
- */
31
- export const SETTABLE = ["draft", "published", "consumed"];
32
- /**
33
- * What a guide is for. The receiving agent behaves completely differently depending on the
34
- * answer, so it is a field rather than something inferred from the body.
35
- *
36
- * transfer work that is finished here and should be repeated there — follow the Steps
37
- * bug something broken there — do NOT follow anything; fix it
38
- * task work nobody has done yet — do what Goal asks, done when Acceptance holds
39
- *
40
- * A guide with no `kind` is a transfer guide: every guide written before this existed is one, and
41
- * defaulting the other way would turn them all into bug reports.
42
- *
43
- * That default is applied once, on read, and written back — `parseFrontmatter` seeds it the way it
44
- * seeds `tags`, so nothing downstream has to decide what an empty kind means. It was decided in six
45
- * places, each with its own `|| "transfer"`, and one of them said `|| "task"`. A guide either says
46
- * what it is or is read as a transfer guide and then says so; there is no third state for anything
47
- * to disagree about.
48
- *
49
- * Which is also why publishing an untyped guide is not refused. Refusing it leaves the document
50
- * exactly as ambiguous as it was and adds a second state — a guide that exists and cannot be
51
- * shared — where the point was to have fewer.
52
- */
53
- /**
54
- * What a guide says to a person, in a sentence or two, and the longest that may be.
55
- *
56
- * Everything an agent writes is written for another agent: exact, dense, full of ids and paths. A
57
- * person scanning their work wants "what is this, and do I need to do anything", so every guide
58
- * carries both — the document is the agent's form, and `summary:` is the human one, shown by default
59
- * in the hub with the document behind "Show details".
60
- *
61
- * Said by the author and refused when absent, like `kind`, for the same reason: a summary written
62
- * afterwards by something that was not there is a second voice that can say what nobody claimed.
63
- */
64
- export const SUMMARY_MAX = 400;
65
-
66
- export const KINDS = ["transfer", "bug", "task"];
67
-
68
- // Body sections in the order a guide should present them. The heading text is what the
69
- // receiving agent keys on, so keep these stable.
70
- export const SECTIONS = [
71
- "Problem",
72
- "Solution shape",
73
- "Decisions and rationale",
74
- "Steps",
75
- "Verification",
76
- "Gotchas",
77
- ];
78
-
79
- /**
80
- * The product areas a bug report offers, mirrored in apps/api/src/guide.ts the same way this
81
- * file's parsing rules are.
82
- *
83
- * Offered, not enforced: nothing rejects an area that is not on this list. A team that ships a
84
- * sixth surface should be able to file against it that afternoon rather than after a release, so
85
- * this is what a menu shows and what turns a slug back into words — not a gate.
86
- */
87
- export const AREAS = [
88
- { slug: "web", label: "Web App" },
89
- { slug: "mobile", label: "Mobile App" },
90
- { slug: "storefront", label: "Storefront Editor" },
91
- { slug: "landing", label: "Landing Page" },
92
- { slug: "auth", label: "Auth Page" },
93
- ];
94
-
95
- /** How badly it is broken. Same accept-anything rule as AREAS. */
96
- export const SEVERITIES = [
97
- { slug: "s1", label: "Blocker" },
98
- { slug: "s2", label: "Major" },
99
- { slug: "s3", label: "Minor" },
100
- { slug: "s4", label: "Cosmetic" },
101
- ];
102
-
103
- /**
104
- * A bug report's sections. The repro is under `Reproduce`, never `Steps`.
105
- *
106
- * That distinction is the whole reason `kind` exists. `Steps` is an instruction to execute —
107
- * "follow its Steps" is what the MCP server tells every agent that pulls a guide — and steps that
108
- * reproduce a defect are the one list that must never be run as a remedy. An agent handed a bug
109
- * with its repro under `Steps` will faithfully reproduce the bug, check the Verification, find it
110
- * false because the bug is real, and report that the guide does not work.
111
- */
112
- export const BUG_SECTIONS = ["Problem", "Reproduce", "Verification", "Gotchas"];
113
-
114
- /**
115
- * One line at the top of every bug, saying what the document is.
116
- *
117
- * `kind: bug` in the frontmatter is the machine-readable answer and `Reproduce` is a heading no
118
- * one executes by mistake, but neither is a sentence. This is, and it is inside the document, so
119
- * it reaches a reader that fetched a share link over plain HTTP and has never heard of Passalong.
120
- */
121
- export const BUG_LEAD =
122
- "> **Bug report.** The steps under Reproduce show the problem \u2014 they are not a fix to " +
123
- "apply. Fix what Problem describes, then check Verification.";
124
-
125
- /**
126
- * A task's sections: a brief for work nobody has done yet, written before it starts.
127
- *
128
- * `Acceptance` is the section that matters. It is what a person checks the finished work against
129
- * before approving it, so a task without one has no way to be done — which is why it is required.
130
- * There is no `Steps`: the agent that takes a task works out how, and what it did comes back as a
131
- * transfer guide, which is where Steps belong.
132
- */
133
- export const TASK_SECTIONS = ["Goal", "Context", "Constraints", "Acceptance", "Out of scope"];
134
-
135
- /** The sections a guide of this kind presents, in order. */
136
- export function sectionsFor(kind) {
137
- if (kind === "bug") return BUG_SECTIONS;
138
- if (kind === "task") return TASK_SECTIONS;
139
- return SECTIONS;
140
- }
141
-
142
- /** The sections it cannot be published without. */
143
- /**
144
- * The sections a kind cannot do without, and the one that has none.
145
- *
146
- * A transfer is context exchange. Context is whatever the next agent needs to not start cold —
147
- * a decision and why, a constraint, where the bodies are — and none of that arrives in six fixed
148
- * headings. Requiring `## Problem` and `## Steps` made every short note into a document: of 56
149
- * follow-ups in real use, the ones that fill this template average 599 words and ten of sixteen
150
- * were never opened by anyone, while the ones that ignored it average 137 words and every single
151
- * one was opened. The template was not raising the floor, it was padding to reach it.
152
- *
153
- * A task and a bug keep theirs, because theirs are not prose. `## Acceptance` is the list a
154
- * hand-in answers line by line, and `## Reproduce` is how a reader sees the defect. Those earn
155
- * their requirement by being read mechanically; `## Steps` on a piece of context does not.
156
- */
157
- const REQUIRED = {
158
- transfer: [],
159
- bug: ["Problem", "Reproduce"],
160
- task: ["Goal", "Acceptance"],
161
- };
162
-
163
- // Fields that hold a list of strings. Everything else is a plain string.
164
- /**
165
- * One tag, in the one style tags are written in: lowercase, words joined by a hyphen.
166
- *
167
- * Nothing used to normalise them, so `custom-fields` and `additional_information` could sit on the
168
- * same guide and the same idea exist twice under two spellings. A tag is a controlled vocabulary,
169
- * not prose — its whole value is that two people who mean the same thing write the same string.
170
- * Mirrors `tag()` in apps/api/src/guide.ts.
171
- */
172
- export function tag(raw) {
173
- return String(raw ?? "")
174
- .trim()
175
- .toLowerCase()
176
- .replace(/[\s_]+/g, "-")
177
- .replace(/[^a-z0-9-]/g, "")
178
- .replace(/-{2,}/g, "-")
179
- .slice(0, 32)
180
- .replace(/^-+|-+$/g, "");
181
- }
182
-
183
- /** A guide's tags: normalised, emptied of blanks, deduped once two spellings become one. */
184
- export function tagList(raw) {
185
- const list = Array.isArray(raw) ? raw : raw === undefined || raw === null ? [] : [raw];
186
- return [...new Set(list.map(tag).filter(Boolean))];
187
- }
188
-
189
- const LIST_FIELDS = new Set(["stack_assumptions", "tags", "blocked_by"]);
190
-
191
- // IDs are short enough to type and say out loud. 8 chars from a 31-letter alphabet with the
192
- // look-alikes removed (0/o, 1/l/i) is ~40 bits: plenty for addressing, not a secret.
193
- const ID_ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
194
- export function newId(length = 8) {
195
- const bytes = randomBytes(length);
196
- let out = "";
197
- for (let i = 0; i < length; i++) out += ID_ALPHABET[bytes[i] % ID_ALPHABET.length];
198
- return out;
199
- }
200
- export const ID_RE = /^[a-z0-9]{6,12}$/;
201
-
202
- function scalar(raw) {
203
- const v = raw.trim();
204
- if (v === "") return "";
205
- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
206
- return v.slice(1, -1).replace(/\\"/g, '"');
207
- }
208
- return v;
209
- }
210
-
211
- /**
212
- * Split a flow sequence's inner text on the commas *between* items, not the ones inside them.
213
- * `quote()` wraps any value containing a comma, so a plain `split(",")` tears those items apart —
214
- * and the pieces keep their stray quotes, because `scalar()` only unwraps a value quoted at both
215
- * ends. Serialising a list and parsing it back has to return the same list.
216
- */
217
- function items(inner) {
218
- const out = [];
219
- let buf = "";
220
- let quoted = null;
221
- for (let i = 0; i < inner.length; i++) {
222
- const c = inner[i];
223
- if (quoted) {
224
- // `quote()` escapes an inner double quote as \", which does not close the value.
225
- if (c === "\\" && quoted === '"' && inner[i + 1] === '"') {
226
- buf += c + inner[i + 1];
227
- i++;
228
- continue;
229
- }
230
- buf += c;
231
- if (c === quoted) quoted = null;
232
- continue;
233
- }
234
- if (c === '"' || c === "'") quoted = c;
235
- if (c === ",") {
236
- out.push(buf);
237
- buf = "";
238
- continue;
239
- }
240
- buf += c;
241
- }
242
- out.push(buf);
243
- return out;
244
- }
245
-
246
- function inlineList(raw) {
247
- const inner = raw.trim().slice(1, -1).trim();
248
- if (inner === "") return [];
249
- return items(inner)
250
- .map(scalar)
251
- .filter((s) => s !== "");
252
- }
253
-
254
- /** Parse a frontmatter block (the text between the --- fences) into an object. */
255
- export function parseFrontmatter(text) {
256
- const meta = {};
257
- const lines = text.split(/\r?\n/);
258
- let listKey = null;
259
- for (const line of lines) {
260
- if (line.trim() === "" || line.trim().startsWith("#")) continue;
261
- // YAML lets a block sequence sit flush with its key, so the indent is optional. Requiring it
262
- // dropped `- item` lines silently: they match no key either, and the field stayed empty.
263
- const item = /^\s*-\s*(.*)$/.exec(line);
264
- if (item && listKey) {
265
- meta[listKey].push(scalar(item[1]));
266
- continue;
267
- }
268
- const kv = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line);
269
- if (!kv) continue;
270
- const [, key, rest] = kv;
271
- if (rest.trim() === "" && LIST_FIELDS.has(key)) {
272
- meta[key] = [];
273
- listKey = key;
274
- } else if (rest.trim().startsWith("[") && rest.trim().endsWith("]")) {
275
- meta[key] = inlineList(rest);
276
- listKey = null;
277
- } else {
278
- meta[key] = LIST_FIELDS.has(key) ? [scalar(rest)] : scalar(rest);
279
- listKey = null;
280
- }
281
- }
282
- // Only the lists every guide has. `blocked_by` is a task's, and defaulting it would write an
283
- // empty one into every guide anybody re-shares.
284
- for (const k of ["stack_assumptions", "tags"]) if (meta[k] === undefined) meta[k] = [];
285
- // Absent is absent. It is NOT a transfer guide.
286
- //
287
- // This line, and the sentence publish_guide tells every agent — "kind: task (the default)" —
288
- // disagreed for thirteen days. An agent that left `kind:` off, believing what it had been told,
289
- // got a transfer guide. 162 of 200 guides in real use are labelled transfer; their titles are
290
- // tasks and bug reports. Everything downstream followed from that: a transfer has no states, so
291
- // nothing could show as in progress; it demands Problem and Steps, so a two-line correction was
292
- // padded to six sections; and there is nowhere to report into one, so agents published a second
293
- // guide to carry what they had found.
294
- //
295
- // So it stays empty and `validate()` refuses it by name. The back-compat this default protected
296
- // is a guide written before kinds existed being re-shared; that guide is re-shared once with an
297
- // explicit `kind: transfer`, which is a sentence its author can write, unlike thirteen days of
298
- // mislabelled work nobody could see.
299
- meta.kind = String(meta.kind ?? "").trim();
300
- // Both ends of this module: what it reads and what it writes are in one style, so a guide
301
- // written before there was a rule comes back normalised and goes out normalised.
302
- meta.tags = tagList(meta.tags);
303
- return meta;
304
- }
305
-
306
- /**
307
- * Frontmatter fields holding more than this format can: a nested map, a block scalar (`|`), a
308
- * sequence under a field that is not a list. `parseFrontmatter` keeps only the key's own line, so
309
- * each of these would come back as `""` with its content gone — a design.md's `colors:` block did
310
- * exactly that, and a publish would have stored the document with every token wiped. The format
311
- * does not grow to hold them (strings and string lists only); the publish refuses them by name
312
- * instead, so nothing is lost without its author being told. Mirrors `unheldFields` in
313
- * apps/api/src/guide.ts, which is the refusal every client meets.
314
- */
315
- export function unheldFields(markdown) {
316
- const m = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(markdown);
317
- if (!m) return [];
318
- const out = new Set();
319
- let key = null;
320
- let listKey = null;
321
- for (const line of m[1].split(/\r?\n/)) {
322
- if (line.trim() === "" || line.trim().startsWith("#")) continue;
323
- if (listKey && /^\s*-\s*/.test(line)) continue;
324
- const kv = /^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$/.exec(line);
325
- if (kv) {
326
- key = kv[1];
327
- listKey = kv[2].trim() === "" && LIST_FIELDS.has(key) ? key : null;
328
- continue;
329
- }
330
- // Anything else is a line the parser skips: an indented child, or an item under a field
331
- // that is not a list. It belongs to the key above it, and that is the key to name.
332
- out.add(key ?? line.trim());
333
- }
334
- return [...out];
335
- }
336
-
337
- function quote(v) {
338
- const s = String(v ?? "");
339
- // Quote anything YAML would misread: colons, leading symbols, or empty.
340
- return /[:#[\]{}"'|>&*!%@`,]|^\s|\s$|^$/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
341
- }
342
-
343
- const META_ORDER = [
344
- "id",
345
- "title",
346
- // Right under the title: it is what a person reads first about it, so it is what the document
347
- // opens with.
348
- "summary",
349
- "kind",
350
- "created",
351
- "author",
352
- "source_context",
353
- // Tasks only: the repo the work is for. Optional — a task without one is not tied to a repo.
354
- "target_context",
355
- // Tasks only: the tasks this one waits for, by id. It is not handed to an agent until a person
356
- // has approved each of them. See migrations/0022_blocks.sql.
357
- "blocked_by",
358
- // Where it came from, in the two senses a guide has one: `source_context` is the repo and branch
359
- // it was written in, `parent` is the guide it was written *out of*.
360
- "parent",
361
- "status",
362
- "team",
363
- "to",
364
- "stack_assumptions",
365
- "tags",
366
- // Bug reports only. `report` is the parent a set of issues was filed under; the other two are
367
- // where it is and how badly it is broken.
368
- "report",
369
- "area",
370
- "severity",
371
- ];
372
-
373
- export function serializeFrontmatter(meta) {
374
- const keys = [
375
- ...META_ORDER.filter((k) => k in meta),
376
- ...Object.keys(meta).filter((k) => !META_ORDER.includes(k)),
377
- ];
378
- const out = [];
379
- for (const key of keys) {
380
- // A kind nobody stated is left unstated. Writing `kind: ""` would put an answer in the
381
- // document that its author never gave, and the next parse would read it back as a stated
382
- // empty rather than an absent one — which is the whole distinction validate() turns on.
383
- if (key === "kind" && !String(meta[key] ?? "").trim()) continue;
384
- const v = key === "tags" ? tagList(meta[key]) : meta[key];
385
- if (Array.isArray(v)) {
386
- out.push(v.length ? `${key}: [${v.map(quote).join(", ")}]` : `${key}: []`);
387
- } else {
388
- out.push(`${key}: ${quote(v)}`);
389
- }
390
- }
391
- return out.join("\n");
392
- }
393
-
394
- /** Split a markdown document into { meta, body }. A document with no frontmatter has empty meta. */
395
- export function parse(markdown) {
396
- const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(markdown);
397
- if (!m) return { meta: parseFrontmatter(""), body: markdown.trim() };
398
- return { meta: parseFrontmatter(m[1]), body: m[2].trim() };
399
- }
400
-
401
- export function serialize({ meta, body }) {
402
- return `---\n${serializeFrontmatter(meta)}\n---\n\n${body.trim()}\n`;
403
- }
404
-
405
- /** Map of "## Heading" → section text, so callers can look at Verification or Gotchas alone. */
406
- export function sections(body) {
407
- const out = {};
408
- let current = null;
409
- for (const line of body.split(/\r?\n/)) {
410
- const h = /^##\s+(.+?)\s*$/.exec(line);
411
- if (h) {
412
- current = h[1];
413
- // A heading written twice adds to its section rather than replacing it. Resetting here lost
414
- // the first block outright, so a guide with two `## Verification` headings was validated
415
- // against only the second — and `splitSections()` in apps/api/src/guide.ts, which the web
416
- // view reads, kept both. The corpus in fixtures/guides is where the two stopped agreeing.
417
- if (!(current in out)) out[current] = "";
418
- continue;
419
- }
420
- if (current) out[current] += `${line}\n`;
421
- }
422
- for (const k of Object.keys(out)) out[k] = out[k].trim();
423
- return out;
424
- }
425
-
426
- /**
427
- * Image targets no reader will ever load. Mirrors `unreachableImages` in apps/api/src/guide.ts,
428
- * which is the authority and refuses the publish; this lets a local share fail before the network.
429
- *
430
- * Only a foreign scheme counts — `attachment://`, `blob:`, `file:`, `data:`. `https:` is left alone
431
- * because embedding an image you host is legitimate, and a relative path is left alone because it
432
- * has always been accepted.
433
- */
434
- export function unreachableImages(markdown) {
435
- const bad = [...String(markdown).matchAll(/!\[[^\]]*\]\(\s*([^)\s]+)/g)]
436
- .map((m) => m[1].trim())
437
- .filter((target) => {
438
- const scheme = /^([a-z][a-z0-9+.\-]*):/i.exec(target);
439
- return Boolean(scheme) && !/^https?$/i.test(scheme[1]);
440
- });
441
- return [...new Set(bad)];
442
- }
443
-
444
- /** Problems that make a guide unfit to publish. Empty array means it is fine. */
445
- export function validate({ meta, body }) {
446
- const errors = [];
447
- if (!meta.title) errors.push("frontmatter needs a title");
448
- if (meta.id && !ID_RE.test(meta.id)) errors.push(`id "${meta.id}" is not a valid passalong id`);
449
- // A parent is optional, and a self-parent is always a mistake worth refusing.
450
- //
451
- // A malformed one is not refused, though, and that is deliberate. `parent:` is a field name this
452
- // format did not reserve until now, so a guide written months ago may already carry one meaning
453
- // something else entirely — and a guide shared then has to still re-share today. Rejecting the
454
- // value would make that document unpublishable by its own author, which is the failure the
455
- // STATUSES comment above describes. The server drops a parent it cannot resolve, so the worst
456
- // case is lineage that silently does not apply rather than work nobody can hand on.
457
- if (meta.parent && meta.id && meta.parent === meta.id)
458
- errors.push("a guide cannot follow itself");
459
- if (meta.status && !STATUSES.includes(meta.status)) {
460
- errors.push(`status must be one of ${STATUSES.join(", ")}`);
461
- }
462
- // Absent is refused here, and this is the one place it can be. Anything read from a document has
463
- // a kind — `parseFrontmatter` seeds it — so what reaches this without one was built field by
464
- // field in code, and code is exactly what should have to say what it is making.
465
- const kind = meta.kind;
466
- const said = String(meta.summary ?? "").trim();
467
- if (!said)
468
- errors.push(
469
- "say it to a person: add `summary:` — one or two plain sentences, no ids or paths, saying what " +
470
- `this is and whether anybody needs to act (${SUMMARY_MAX} characters at most)`,
471
- );
472
- else if (said.length > SUMMARY_MAX)
473
- errors.push(`summary is ${said.length} characters; ${SUMMARY_MAX} at most`);
474
- if (!kind) errors.push(`say what this is: kind must be one of ${KINDS.join(", ")}`);
475
- else if (!KINDS.includes(kind)) errors.push(`kind must be one of ${KINDS.join(", ")}`);
476
- const have = sections(body);
477
- for (const s of REQUIRED[kind] || REQUIRED.transfer) {
478
- if (!have[s]) errors.push(`missing "## ${s}" section`);
479
- }
480
- // A bug whose repro sits under `Steps` is the failure mode `kind` exists to prevent, and it is
481
- // worth catching at publish rather than letting an agent discover it by reproducing the bug.
482
- if (kind === "bug" && have.Steps) {
483
- errors.push('a bug uses "## Reproduce", not "## Steps" — an agent executes Steps');
484
- }
485
- if (body.includes("<!-- passalong:")) errors.push("template placeholders are still in the body");
486
- // An agent that has a file id but never called attach_screenshot writes the id as if it were a
487
- // link. It publishes, and stores a guide whose screenshot was never uploaded anywhere.
488
- for (const target of unreachableImages(body))
489
- errors.push(
490
- `${target} is not a URL a reader can load — attach the image first and use the URL it returns`,
491
- );
492
- return errors;
493
- }
494
-
495
- /** Fill in what a fresh guide needs before it can be stored. Returns a new object. */
496
- export function stamp(guide, defaults = {}) {
497
- const meta = { ...guide.meta };
498
- if (!meta.id) meta.id = newId();
499
- if (!meta.created) meta.created = new Date().toISOString();
500
- // Ready unless whoever wrote it said draft: a guide sent to someone is meant to reach them, and
501
- // drafts were where addressed work sat forgotten (every draft in production on 2026-10-15).
502
- if (!meta.status) meta.status = "published";
503
- for (const [k, v] of Object.entries(defaults))
504
- if (meta[k] === undefined || meta[k] === "") meta[k] = v;
505
- return { meta, body: guide.body };
506
- }
507
-
508
- /**
509
- * One bug, as the guide it becomes.
510
- *
511
- * This is the filing shape: fields in, a valid document out, so nothing that files a bug has to
512
- * remember which heading the repro goes under. The hub's form assembles its own because it has
513
- * more to say — a device string, screenshots, and a rich text field it converts — but the section
514
- * names and the order are these, and `validate()` is the thing both answer to.
515
- */
516
- export function bugGuide({
517
- title = "",
518
- // What a person reads about it. A bug's own title is that when nothing else was said, and it is
519
- // the author's own words, stated here by the caller that knows it is building a bug — not a
520
- // default for something an author left off.
521
- summary = "",
522
- problem = "",
523
- reproduce = "",
524
- verification = "",
525
- gotchas = "",
526
- severity = "s3",
527
- area = "",
528
- report = "",
529
- environment = "",
530
- status = "published",
531
- evidence = [],
532
- } = {}) {
533
- /**
534
- * Screenshots, as markdown inside the document.
535
- *
536
- * They go under Problem rather than a heading of their own, because for a visual defect the
537
- * picture *is* the problem statement and a bug's sections are a fixed set — a seventh heading
538
- * would change the shape every reader keys on to add a line that already belongs in the first
539
- * one.
540
- *
541
- * Naming them in the body is also the only thing that binds them: `claimShots` claims whatever
542
- * the markdown points at, scoped to the author, so a URL listed anywhere else would upload
543
- * evidence that no guide owns and the nightly sweep deletes.
544
- */
545
- const shots = (Array.isArray(evidence) ? evidence : [evidence])
546
- .map((s) => String(s ?? "").trim())
547
- .filter(Boolean)
548
- // A caller that already has the markdown line from `attach_screenshot` passes it through
549
- // whole; one holding just the URL gets it wrapped. Both reach the same document.
550
- .map((s) => (s.startsWith("![") ? s : `![evidence](${s})`));
551
- const body = [
552
- // Written into the document, not added by whatever served it.
553
- //
554
- // get_guide says this in front of the markdown, but that only reaches an MCP client. A share
555
- // link is plain markdown any agent can fetch over HTTP, and the route that serves it cannot
556
- // decorate the response: `api.byLink` pulls guides *through* that route and re-serialises what
557
- // it gets, so anything added there would be written to disk and published back on the next
558
- // share. A line in the body survives that round trip unchanged and reaches every reader.
559
- BUG_LEAD,
560
- "",
561
- "## Problem",
562
- problem.trim() || "_No description given._",
563
- ];
564
- if (shots.length) body.push("", ...shots);
565
- body.push(
566
- "",
567
- "## Reproduce",
568
- reproduce.trim() || "_Not recorded — the description above is what there is._",
569
- );
570
- // Both are optional and both are worse than absent when empty: a Verification heading with
571
- // nothing under it says nobody knows what fixed looks like, on the section a fixer reads first.
572
- if (verification.trim()) body.push("", "## Verification", verification.trim());
573
- if (gotchas.trim()) body.push("", "## Gotchas", gotchas.trim());
574
-
575
- const meta = {
576
- title,
577
- summary: String(summary || "").trim() || title,
578
- kind: "bug",
579
- status,
580
- tags: ["bug", environment, area].filter(Boolean),
581
- };
582
- if (report) meta.report = report;
583
- if (area) meta.area = area;
584
- if (severity) meta.severity = severity;
585
- if (environment) meta.source_context = environment;
586
- return serialize({ meta, body: body.join("\n") });
587
- }
588
-
589
- /** A draft with the section skeleton. Placeholders are HTML comments so they vanish when rendered. */
590
- export function template(meta = {}) {
591
- // A task unless asked otherwise: most guides assign work. Every skeleton writes its kind out —
592
- // absent is now refused rather than read as transfer, so a template that left it off would
593
- // produce a document its own author could not publish.
594
- if (meta.kind === "bug") return bugTemplate(meta);
595
- if (meta.kind !== "transfer") return taskTemplate(meta);
596
- const body = [
597
- "## Problem",
598
- "<!-- passalong: What was broken or needed, in two or three sentences. -->",
599
- "",
600
- "## Solution shape",
601
- "<!-- passalong: The approach at a high level, before any code. -->",
602
- "",
603
- "## Decisions and rationale",
604
- "<!-- passalong: What was chosen, what was rejected, and why. This lets the receiver adapt instead of copy. -->",
605
- "",
606
- "## Steps",
607
- "<!-- passalong: Concrete implementation steps. Mark context-specific parts: ASSUMES: Postgres. If MySQL, adjust X. -->",
608
- "",
609
- "## Verification",
610
- "<!-- passalong: Commands, expected outputs, test cases that prove it worked. -->",
611
- "",
612
- "## Gotchas",
613
- "<!-- passalong: What failed along the way and why. Often the highest-value section. -->",
614
- ].join("\n");
615
- return serialize({
616
- meta: {
617
- title: "",
618
- summary: "",
619
- author: "",
620
- source_context: "",
621
- status: "published",
622
- stack_assumptions: [],
623
- tags: [],
624
- ...meta,
625
- kind: "transfer",
626
- },
627
- body,
628
- });
629
- }
630
-
631
- /**
632
- * One bug, as a guide. Four sections and no `Steps` anywhere in it.
633
- *
634
- * `Verification` carries its usual meaning and does its usual job: it is what the person who
635
- * fixes this runs to show they did, and it is what they are answering when they give a verdict.
636
- * For a bug that is simply the behaviour that should have happened.
637
- */
638
- function bugTemplate(meta = {}) {
639
- const body = [
640
- "## Problem",
641
- "<!-- passalong: What is broken, and what it stops someone doing. -->",
642
- "",
643
- "## Reproduce",
644
- "<!-- passalong: How to see it. These steps produce the bug — they are not a fix to apply. -->",
645
- "",
646
- "## Verification",
647
- "<!-- passalong: What should happen instead, as something the fixer can check. -->",
648
- "",
649
- "## Gotchas",
650
- "<!-- passalong: Anything already ruled out, or that made it hard to pin down. -->",
651
- ].join("\n");
652
- return serialize({
653
- meta: {
654
- title: "",
655
- summary: "",
656
- kind: "bug",
657
- author: "",
658
- source_context: "",
659
- status: "published",
660
- severity: "s3",
661
- stack_assumptions: [],
662
- tags: ["bug"],
663
- ...meta,
664
- },
665
- body,
666
- });
667
- }
668
-
669
- /**
670
- * One task, as a guide: the brief an agent takes before any work exists.
671
- *
672
- * `target_context` is the repo the work is for, and it is left empty rather than defaulted to the
673
- * repo the task was written in: a task written in one repo is often for another, and one aimed at
674
- * the wrong repo is picked up by the wrong agent.
675
- */
676
- function taskTemplate(meta = {}) {
677
- const body = [
678
- "## Goal",
679
- "<!-- passalong: What should be true when this is done, in two or three sentences. -->",
680
- "",
681
- "## Context",
682
- "<!-- passalong: What the agent needs to know first: where the code lives, what exists already, links. -->",
683
- "",
684
- "## Constraints",
685
- "<!-- passalong: What must not change, what to use or avoid, limits on scope or approach. -->",
686
- "",
687
- "## Acceptance",
688
- "<!-- passalong: How a person checks it is done: commands, expected output, behaviour to see. -->",
689
- "",
690
- "## Out of scope",
691
- "<!-- passalong: What looks related but is not part of this task. -->",
692
- ].join("\n");
693
- return serialize({
694
- meta: {
695
- title: "",
696
- summary: "",
697
- kind: "task",
698
- author: "",
699
- source_context: "",
700
- target_context: "",
701
- status: "published",
702
- stack_assumptions: [],
703
- tags: [],
704
- ...meta,
705
- },
706
- body,
707
- });
708
- }
709
-
710
- /** Strip the template comments so an untouched section reads as empty rather than as a prompt. */
711
- export function stripPlaceholders(body) {
712
- return body.replace(/^<!-- passalong:.*?-->\n?/gm, "").trim();
713
- }
714
-
715
- // ---- reading a guide in parts, and how big one may be -----------------------------------------
716
- // Mirrors apps/api/src/sections.ts — apps/api/test/sections.test.mjs holds the two to the same
717
- // answers. Most of what is stored is a few very large guides (six of 123 held 72% of the text), and
718
- // an agent that wants only the Acceptance of a long task should not read the rest first.
719
-
720
- /** Over this a guide is large: its readers are offered the outline first, and its author a warning. */
721
- export const GUIDE_WARN = 20_000;
722
- /** Over this a new guide is refused by the server. A guide stored before it existed may keep its size. */
723
- export const GUIDE_MAX = 60_000;
724
-
725
- /** Where each heading starts, outside the frontmatter and outside code fences. */
726
- function headingLines(markdown) {
727
- const fm = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(markdown);
728
- const bodyStart = fm ? fm[0].length : 0;
729
- const lines = [];
730
- let fence = "";
731
- let at = bodyStart;
732
- for (const raw of markdown.slice(bodyStart).split("\n")) {
733
- const mark = /^\s*(```|~~~)/.exec(raw)?.[1];
734
- if (mark) fence = fence ? (fence === mark ? "" : fence) : mark;
735
- const m = !fence && !mark ? /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(raw) : null;
736
- if (m) lines.push({ heading: m[2].trim(), level: m[1].length, start: at });
737
- at += raw.length + 1;
738
- }
739
- return lines;
740
- }
741
-
742
- /** The end of the section that begins at lines[i]: the next heading at its level or above. */
743
- function sectionEnd(lines, i, total) {
744
- for (let j = i + 1; j < lines.length; j++)
745
- if (lines[j].level <= lines[i].level) return lines[j].start;
746
- return total;
747
- }
748
-
749
- /** Every heading, with the characters in its section (nested ones included). */
750
- export function outline(markdown) {
751
- const lines = headingLines(markdown);
752
- return lines.map((l, i) => ({
753
- heading: l.heading,
754
- level: l.level,
755
- chars: sectionEnd(lines, i, markdown.length) - l.start,
756
- }));
757
- }
758
-
759
- /** One section by its heading — exact, then by its start, then by what it contains. */
760
- export function section(markdown, name) {
761
- const lines = headingLines(markdown);
762
- const want = String(name ?? "")
763
- .trim()
764
- .toLowerCase();
765
- if (!want) return null;
766
- const at = (test) => lines.findIndex((l) => test(l.heading.toLowerCase()));
767
- const i = [
768
- at((h) => h === want),
769
- at((h) => h.startsWith(want)),
770
- at((h) => h.includes(want)),
771
- ].find((n) => n >= 0);
772
- if (i === undefined) return null;
773
- return {
774
- heading: lines[i].heading,
775
- text: markdown.slice(lines[i].start, sectionEnd(lines, i, markdown.length)).trimEnd(),
776
- };
777
- }
778
-
779
- /** What a guide's author is told when it is on the large side. */
780
- export function sizeWarning(chars) {
781
- return chars > GUIDE_WARN
782
- ? `this guide is ${chars.toLocaleString("en")} characters, and over ${GUIDE_WARN.toLocaleString("en")} ` +
783
- "it is read by outline first. Put logs, dumps and long output in a file (attach_file) and " +
784
- "link it; keep the guide to what the next agent has to act on."
785
- : null;
786
- }
787
-
788
- /**
789
- * What an agent is shown instead of a large guide: who it is, what it is, and where its parts are.
790
- * `path` is where the whole of it was written, for a search rather than a read.
791
- */
792
- export function outlineText(markdown, { id = "", path = "" } = {}) {
793
- const { meta } = parse(markdown);
794
- const rows = outline(markdown)
795
- .filter((h) => h.level <= 3)
796
- .map(
797
- (h) => `${" ".repeat(h.level - 1)}- ${h.heading} (${h.chars.toLocaleString("en")} chars)`,
798
- );
799
- return [
800
- `${meta.title || id} — ${meta.kind || "guide"}, ${markdown.length.toLocaleString("en")} characters`,
801
- meta.summary ? `Summary: ${meta.summary}` : "",
802
- "",
803
- "Sections:",
804
- ...rows,
805
- "",
806
- `Read one with get_guide ref=${id} section="<heading>", or the whole thing with full=true.` +
807
- (path ? ` The full text is also on disk at ${path}, if you only need to search it.` : ""),
808
- ]
809
- .filter((l, i, a) => l !== "" || a[i - 1] !== "")
810
- .join("\n");
811
- }