@orbytes/astrolab 0.3.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 (94) hide show
  1. package/LICENSE +37 -0
  2. package/README.md +410 -0
  3. package/bin/lab-cull.mjs +401 -0
  4. package/bin/pin-gallery.mjs +121 -0
  5. package/defaults.mjs +120 -0
  6. package/dist/core/astro-integration.js +130 -0
  7. package/dist/core/index.js +6 -0
  8. package/dist/core/lib-paths.js +29 -0
  9. package/dist/core/options.js +173 -0
  10. package/dist/core/utils/get-exports.js +52 -0
  11. package/dist/core/utils/invariant.js +12 -0
  12. package/dist/core/utils/kebab-case.js +15 -0
  13. package/dist/core/utils/path-builder.js +22 -0
  14. package/dist/core/utils/path.js +53 -0
  15. package/dist/core/virtual-module/get-story-modules.js +60 -0
  16. package/dist/core/virtual-module/story-modules.js +8 -0
  17. package/dist/core/virtual-module/virtual-module-ids.js +22 -0
  18. package/dist/core/virtual-module/virtual-routes.js +83 -0
  19. package/dist/core/virtual-module/vite-plugin.js +98 -0
  20. package/docs/PIN-CONTRACT.md +263 -0
  21. package/docs/PIN.md +429 -0
  22. package/index.d.ts +279 -0
  23. package/index.mjs +347 -0
  24. package/package.json +93 -0
  25. package/src/Empty.astro +4 -0
  26. package/src/Home.astro +298 -0
  27. package/src/LabHead.astro +1102 -0
  28. package/src/core/LICENSE-astrobook +166 -0
  29. package/src/core/astro-integration.ts +166 -0
  30. package/src/core/client.ts +89 -0
  31. package/src/core/index.ts +7 -0
  32. package/src/core/lib/components/empty.astro +1 -0
  33. package/src/core/lib/components/head.astro +1 -0
  34. package/src/core/lib/components/home.astro +8 -0
  35. package/src/core/lib/components/with-decorators.astro +22 -0
  36. package/src/core/lib/pages/app.astro +19 -0
  37. package/src/core/lib/pages/preview.astro +17 -0
  38. package/src/core/lib/pages/story.astro +16 -0
  39. package/src/core/lib-paths.ts +72 -0
  40. package/src/core/options.ts +262 -0
  41. package/src/core/utils/get-exports.ts +59 -0
  42. package/src/core/utils/invariant.ts +13 -0
  43. package/src/core/utils/kebab-case.ts +30 -0
  44. package/src/core/utils/path-builder.ts +45 -0
  45. package/src/core/utils/path.ts +80 -0
  46. package/src/core/virtual-module/get-story-modules.ts +110 -0
  47. package/src/core/virtual-module/story-modules.ts +9 -0
  48. package/src/core/virtual-module/virtual-module-ids.ts +17 -0
  49. package/src/core/virtual-module/virtual-routes.ts +130 -0
  50. package/src/core/virtual-module/vite-plugin.ts +125 -0
  51. package/src/pin/board.mjs +1521 -0
  52. package/src/pin/index.mjs +666 -0
  53. package/src/pin/shot.mjs +427 -0
  54. package/src/pin/source-stamp.mjs +159 -0
  55. package/src/pin/tickets.mjs +697 -0
  56. package/src/pin/toolbar.js +3181 -0
  57. package/src/shell/Browse.astro +371 -0
  58. package/src/shell/CardGrid.astro +297 -0
  59. package/src/shell/Viewport.astro +1330 -0
  60. package/src/shell/index.json.ts +12 -0
  61. package/src/shell/lab-index.ts +344 -0
  62. package/src/shell/lab-params.ts +245 -0
  63. package/src/shell/live-files.mjs +164 -0
  64. package/src/shell/marks.mjs +136 -0
  65. package/src/types/index.ts +6 -0
  66. package/src/types/types.ts +239 -0
  67. package/src/types/virtual.d.ts +29 -0
  68. package/src/ui/components/app.astro +13 -0
  69. package/src/ui/components/build-path.ts +13 -0
  70. package/src/ui/components/build-tree.ts +108 -0
  71. package/src/ui/components/collapse-duration.ts +28 -0
  72. package/src/ui/components/compress-terms.ts +10 -0
  73. package/src/ui/components/dashboard-layout.astro +39 -0
  74. package/src/ui/components/home.astro +65 -0
  75. package/src/ui/components/layout.astro +110 -0
  76. package/src/ui/components/preview-layout.astro +109 -0
  77. package/src/ui/components/sidebar-button-fullscreen.astro +38 -0
  78. package/src/ui/components/sidebar-button-search.astro +23 -0
  79. package/src/ui/components/sidebar-button-theme.astro +9 -0
  80. package/src/ui/components/sidebar-button.astro +24 -0
  81. package/src/ui/components/sidebar-resize-handle.astro +74 -0
  82. package/src/ui/components/sidebar-search-panel.astro +41 -0
  83. package/src/ui/components/sidebar-search-script.ts +103 -0
  84. package/src/ui/components/sidebar-title.astro +17 -0
  85. package/src/ui/components/sidebar-tree-node.astro +143 -0
  86. package/src/ui/components/sidebar-tree.astro +84 -0
  87. package/src/ui/components/sidebar.astro +29 -0
  88. package/src/ui/components/theme-message.ts +26 -0
  89. package/src/ui/components/theme-script.astro +71 -0
  90. package/src/ui/components/theme-toggle.astro +63 -0
  91. package/src/ui/components/theme.ts +32 -0
  92. package/src/ui/index.ts +4 -0
  93. package/src/ui/lab.css +549 -0
  94. package/virtual.d.ts +42 -0
@@ -0,0 +1,697 @@
1
+ // orbytes-pin — ticket allocation, rendering and reading.
2
+ //
3
+ // One ticket is one markdown file at `<repo>/backlog/tasks/pin-<NNN>-<slug>.md`, in backlog.md's
4
+ // own frontmatter vocabulary plus a `pin:` block this package owns. The shape is fixed by
5
+ // ../../docs/PIN-CONTRACT.md § "Ticket format" — do not redesign it here.
6
+ //
7
+ // Every path this module writes into a ticket is REPO-RELATIVE (contract non-negotiable 2): a
8
+ // ticket is committed and read by an agent on another machine, so an absolute /Users/... path in
9
+ // one is a leak and a lie. The repo root is found by walking up from the Astro config root to the
10
+ // first directory holding `.git`, and nothing here accepts a root it did not find that way.
11
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
12
+ import { dirname, join, resolve, sep } from "node:path";
13
+
14
+ /**
15
+ * The six states a ticket can be in, in board order. Nothing else may be written to `status`.
16
+ *
17
+ * Ruled 2026-09-22, one line per status:
18
+ *
19
+ * · `Backlog` — for when an agent should not pick it up yet
20
+ * · `Ready for agent` — replaces `To Do`
21
+ * · `In Progress` — an agent has it and is working on it
22
+ * · `Ready for review` — done; a human in the loop moves it into resolved
23
+ * · `Resolved` — accepted
24
+ * · `Cancelled` — just an archive for cancelled work; items in here must not override
25
+ * anything or take up context space for an agent
26
+ *
27
+ * Three of those carry a rule, and each is enforced by something in this file rather than left as
28
+ * a convention to remember:
29
+ *
30
+ * 1. **`Ready for agent` is the only status an agent may start work from** (› `isAgentReady`).
31
+ * `Backlog` means explicitly not yet. The same vocabulary was rolled out across the
32
+ * issue tracker the day before, deliberately, so the local board and the tracker agree.
33
+ * 2. **Only a human sets `Resolved`. No agent moves a ticket there, ever.** That is the whole
34
+ * reason `Ready for review` exists: until 2026-09-22 the last column was `Done`, which
35
+ * conflated "an agent finished it" with "a human accepted it", so work waiting on review
36
+ * had nowhere to sit and nothing on the board could say *this one wants you*.
37
+ * 3. **`Cancelled` is an archive that must not reach an agent's context** (› `listTickets`,
38
+ * which excludes it unless asked). Not sorted last, not greyed out — absent.
39
+ *
40
+ * `To Do` and `Done` survive as legacy read aliases (› `normaliseStatus`) and are never written
41
+ * again.
42
+ */
43
+ export const STATUSES = ["Backlog", "Ready for agent", "In Progress", "Ready for review", "Resolved", "Cancelled"];
44
+
45
+ /** Not for agents yet — still being decided, or it waits on something else. */
46
+ export const BACKLOG = "Backlog";
47
+
48
+ /** The ONLY status an agent may start work from, and what a new pin is written as. */
49
+ export const READY_FOR_AGENT = "Ready for agent";
50
+
51
+ /** An agent has picked it up and is working on it. */
52
+ export const IN_PROGRESS = "In Progress";
53
+
54
+ /** Finished by an agent, not yet accepted. The human review queue, and the point of the 2026-09-22 change. */
55
+ export const READY_FOR_REVIEW = "Ready for review";
56
+
57
+ /** A human accepted the work. Only a human ever sets it; no agent moves a ticket here. */
58
+ export const RESOLVED = "Resolved";
59
+
60
+ /** The archive. Kept on disk, kept off every agent-facing read. */
61
+ export const CANCELLED = "Cancelled";
62
+
63
+ /** What a freshly picked ticket is written as — an agent may take it straight away. */
64
+ export const DEFAULT_STATUS = READY_FOR_AGENT;
65
+
66
+ /** The two statuses that end a ticket's life: accepted, or abandoned. */
67
+ export const CLOSED_STATUSES = Object.freeze([RESOLVED, CANCELLED]);
68
+
69
+ /**
70
+ * "Open" means neither `Resolved` nor `Cancelled` — every surface, one definition.
71
+ *
72
+ * A list and a predicate rather than a rule each surface re-derives, because there are now four
73
+ * open statuses and two surfaces (this board's columns and chips, the in-page panel's Open tab).
74
+ * Two places counting "open" their own way is how they come to disagree by one ticket.
75
+ *
76
+ * Use `isOpen(status)` on a status read off a file — it normalises the legacy aliases first,
77
+ * which a bare `OPEN_STATUSES.includes(...)` does not.
78
+ */
79
+ export const OPEN_STATUSES = Object.freeze(STATUSES.filter((s) => !CLOSED_STATUSES.includes(s)));
80
+
81
+ /**
82
+ * Statuses written before 2026-09-22, mapped onto today's vocabulary.
83
+ *
84
+ * Every ticket and every reader written before the split still says these, so they must keep
85
+ * parsing. `To Do` → `Ready for agent` is the ruled replacement — that status was introduced to
86
+ * take `To Do`'s place; `Done` → `Resolved` because a `Done` ticket on a three-column board with
87
+ * no review step was one a human had already let go by. `updateTicket` rewrites the line on the
88
+ * next write of that ticket, so the aliases drain themselves rather than living on disk forever.
89
+ */
90
+ const LEGACY_STATUS = Object.freeze({ "To Do": READY_FOR_AGENT, Done: RESOLVED });
91
+
92
+ /**
93
+ * A status as the board means it: today's vocabulary, with the legacy aliases resolved.
94
+ *
95
+ * Everything else is handed back trimmed and unchanged — an unknown status is a real thing to see
96
+ * on the board (it gets its own undroppable column), never something to round to a known one.
97
+ *
98
+ * @param {unknown} status the raw `status:` value read off a ticket
99
+ * @returns {string} the status in today's vocabulary, or "" when there was none
100
+ */
101
+ export function normaliseStatus(status) {
102
+ const s = String(status ?? "").trim();
103
+ return LEGACY_STATUS[s] ?? s;
104
+ }
105
+
106
+ /**
107
+ * Is this ticket open — neither accepted nor cancelled?
108
+ *
109
+ * Deliberately a predicate on the status and not on the ticket, so both surfaces and any agent
110
+ * reading `listTickets()` ask the same question the same way. It handles the legacy aliases,
111
+ * which is the whole reason to call it rather than compare strings: `isOpen("Done")` is `false`.
112
+ *
113
+ * @param {unknown} status
114
+ */
115
+ export function isOpen(status) {
116
+ return !CLOSED_STATUSES.includes(normaliseStatus(status));
117
+ }
118
+
119
+ /**
120
+ * Is this ticket archived — cancelled, and therefore invisible to every agent-facing read?
121
+ *
122
+ * Ruled 2026-09-22: items in here must not override anything or take up context space for an
123
+ * agent. `listTickets` drops these unless explicitly asked for them; `/pin` is the one place
124
+ * they are still visible, because an archive nobody can open is a delete.
125
+ *
126
+ * @param {unknown} status
127
+ */
128
+ export function isCancelled(status) {
129
+ return normaliseStatus(status) === CANCELLED;
130
+ }
131
+
132
+ /**
133
+ * May an agent start work on a ticket in this status? Exactly one status says yes.
134
+ *
135
+ * `Backlog` is the explicit no — for when an agent should not pick it up yet. An agent that
136
+ * picks work off the board asks this question and nothing else; "not Resolved" is not the test,
137
+ * and neither is "To Do", which no longer exists.
138
+ *
139
+ * @param {unknown} status
140
+ */
141
+ export function isAgentReady(status) {
142
+ return normaliseStatus(status) === READY_FOR_AGENT;
143
+ }
144
+
145
+ /** The opening fence of the body block that carries every pin field. Matched, never guessed. */
146
+ export const PIN_FENCE = "```yaml pin";
147
+
148
+ /**
149
+ * The repo root: the first ancestor of `from` that contains `.git`. Throws rather than guessing,
150
+ * because every path in a ticket is relative to it and a wrong root silently poisons every ticket.
151
+ * @param {string} from absolute directory to start from (the Astro config root)
152
+ */
153
+ export function findRepoRoot(from) {
154
+ let dir = resolve(from);
155
+ for (;;) {
156
+ if (existsSync(join(dir, ".git"))) return dir;
157
+ const up = dirname(dir);
158
+ if (up === dir) break;
159
+ dir = up;
160
+ }
161
+ throw new Error(`orbytes-pin: no .git found above ${from} — cannot resolve the repo root, so no ticket path can be made repo-relative.`);
162
+ }
163
+
164
+ /** The repo directory name — the `<project>` segment of the screenshot archive path. */
165
+ export const projectName = (repoRoot) => resolve(repoRoot).split(sep).filter(Boolean).pop();
166
+
167
+ /** lowercase, non-alphanumerics collapsed to `-`, trimmed to 40 chars, no leading/trailing dash. */
168
+ export function slugify(title) {
169
+ const s = String(title ?? "")
170
+ .toLowerCase()
171
+ .replace(/[^a-z0-9]+/g, "-")
172
+ .replace(/^-+|-+$/g, "")
173
+ .slice(0, 40)
174
+ .replace(/-+$/g, "");
175
+ return s || "untitled";
176
+ }
177
+
178
+ /**
179
+ * The ticket title: the first non-empty line of the pin comment, trimmed to 70 chars.
180
+ *
181
+ * 70 and not 80, and the number is load-bearing. backlog.md re-serialises frontmatter with
182
+ * js-yaml on every write, and js-yaml folds a scalar whose RAW LENGTH exceeds
183
+ * `lineWidth - indent` — 80 - 2 = 78 for a top-level key. Measured against backlog.md 1.52.0
184
+ * (2026-09-21) by writing tickets at every title length from 55 to 95, running
185
+ * `backlog task edit <n> -s "In Progress"` on each and reading the files back: 78 characters
186
+ * stayed on one line, 79 came back as a `>-` block, and the boundary did not move when the word
187
+ * boundaries were changed (`"ab " * 26`) or when the title was quoted by a colon. So it is the
188
+ * title's own length that decides, not the width of the rendered `title: …` line — the key and
189
+ * its separator cost nothing.
190
+ *
191
+ * The old cap of 80 sat two characters over that line, so a full-length title folded the first
192
+ * time a card was dragged to a new status, turning a file a person reads into a wrapped block.
193
+ * 70 leaves eight characters of headroom for an indent level or a js-yaml default that moves.
194
+ * `parseTicket` reads the folded form regardless; this half only stops provoking it.
195
+ */
196
+ export function titleFrom(comment) {
197
+ const line = String(comment ?? "")
198
+ .split(/\r?\n/)
199
+ .map((l) => l.trim())
200
+ .find((l) => l.length > 0);
201
+ // trimEnd: the slice can cut mid-phrase and leave a trailing space, which `yamlScalar` would
202
+ // then have to quote — a quoted title where every neighbour is bare, for no reason a reader sees.
203
+ return (line ?? "Untitled pin").slice(0, 70).trimEnd();
204
+ }
205
+
206
+ /**
207
+ * The next free number: max of every `pin-<NNN>` already in `tasks/`, plus one. Scanning
208
+ * filenames (not an index file) means a ticket deleted by hand never hands its number out twice
209
+ * to a reader of git history, and there is no counter to fall out of sync.
210
+ */
211
+ export function nextNumber(tasksDir) {
212
+ let max = 0;
213
+ if (existsSync(tasksDir)) {
214
+ for (const name of readdirSync(tasksDir)) {
215
+ const m = /^pin-(\d+)(?:-|\.md$)/.exec(name);
216
+ if (m) max = Math.max(max, Number(m[1]));
217
+ }
218
+ }
219
+ return max + 1;
220
+ }
221
+
222
+ export const pad = (n) => String(n).padStart(3, "0");
223
+
224
+ /**
225
+ * A YAML scalar that survives a round trip. Bare where that is unambiguous, single-quoted
226
+ * otherwise (YAML escapes a single quote by doubling it). `force` is for the fields the contract
227
+ * shows quoted whatever their content — dates, selectors, raw HTML.
228
+ */
229
+ function yamlScalar(value, force = false) {
230
+ const s = String(value ?? "");
231
+ const unsafe =
232
+ force ||
233
+ s === "" ||
234
+ s !== s.trim() ||
235
+ /^[-?:,\[\]{}#&*!|>'"%@`]/.test(s) ||
236
+ /:\s|\s#/.test(s) ||
237
+ /[\n\r]/.test(s) ||
238
+ /^(true|false|null|~|yes|no|on|off)$/i.test(s) ||
239
+ /^[+-]?(\d|\.\d)/.test(s);
240
+ if (!unsafe) return s;
241
+ return `'${s.replace(/\r?\n/g, " ").replace(/'/g, "''")}'`;
242
+ }
243
+
244
+ const flowNums = (obj, keys) => `{ ${keys.map((k) => `${k}: ${Number(obj?.[k]) || 0}`).join(", ")} }`;
245
+
246
+ /**
247
+ * The second label, beside `pin`. Derived from the section folder a stamped source names
248
+ * (`.../sections/About01Hero/V1/...` → `hero`), so tickets about one section are greppable.
249
+ * Underspecified by the contract, whose example shows `labels: [pin, hero]` on a hero section;
250
+ * this rule reproduces that. No resolvable section → `[pin]` alone, never a guess.
251
+ */
252
+ export function labelsFor(source) {
253
+ const m = /\/sections\/([A-Za-z0-9]+)\//.exec(String(source ?? ""));
254
+ if (!m) return ["pin"];
255
+ const tail = m[1].replace(/^[A-Za-z]+\d+/, "") || m[1];
256
+ const label = slugify(tail.replace(/([a-z0-9])([A-Z])/g, "$1-$2"));
257
+ return label && label !== "untitled" ? ["pin", label] : ["pin"];
258
+ }
259
+
260
+ /**
261
+ * The ticket's markdown. Frontmatter carries ONLY backlog.md's own schema; everything this
262
+ * package needs lives in a fenced ```yaml pin block in the BODY.
263
+ *
264
+ * Why, measured against the installed backlog.md on 2026-09-21 — and this is the whole reason
265
+ * the shape is not the one ../../docs/PIN-CONTRACT.md § Ticket format drew:
266
+ * · Any write through backlog — the CLI, or dragging a card's status in the web UI —
267
+ * re-serialises frontmatter to backlog's own schema and silently drops every unknown key.
268
+ * `backlog task edit <n> -s Done` on a contract-shaped ticket erased dispatch, source,
269
+ * selector, rect, shot and outer_html. The first time a card was moved, the ticket stopped
270
+ * being actionable. The body and its image survive that write untouched.
271
+ * · backlog's detail view renders only its own known headings. Under `## Comment` the panel
272
+ * read "No description" and `document.images` was empty — the comment AND the screenshot
273
+ * were invisible on the board. Under `## Description` both render.
274
+ *
275
+ * Order inside the body is deliberate: what the reviewer reads (the comment, then the picture)
276
+ * comes before what an agent reads.
277
+ */
278
+ export function renderTicket({ id, num, title, createdDate, labels, pin, comment }) {
279
+ const shotName = `pin-${pad(num)}.png`;
280
+ return [
281
+ "---",
282
+ `id: ${id}`,
283
+ `title: ${yamlScalar(title)}`,
284
+ // `Ready for agent`, not `Backlog`: a person picked this element and typed a comment about
285
+ // it, so it is work they want done. `Backlog` is the status a ticket is moved TO to wait.
286
+ // Written bare, with no quoting, which is safe because DEFAULT_STATUS comes from the closed
287
+ // allowlist above and every member of it is a plain YAML scalar.
288
+ `status: ${DEFAULT_STATUS}`,
289
+ "assignee: []",
290
+ `created_date: ${yamlScalar(createdDate, true)}`,
291
+ `labels: [${labels.join(", ")}]`,
292
+ "dependencies: []",
293
+ "priority: medium",
294
+ "---",
295
+ "",
296
+ // backlog.md renders this heading and no other. Do not rename it.
297
+ "## Description",
298
+ "",
299
+ // Verbatim. Never summarised, never rewritten — contract § Notes on the fields.
300
+ String(comment).replace(/\r\n/g, "\n").trimEnd(),
301
+ "",
302
+ // backlog.md's own served route, which is NOT the on-disk path in `shot:` below. Both are
303
+ // required and they differ on purpose.
304
+ `![](/assets/${shotName})`,
305
+ "",
306
+ // The info string is `yaml pin` so a reader matches the fence, never a position in the file.
307
+ PIN_FENCE,
308
+ `dispatch: ${pin.dispatch}`,
309
+ `source: ${yamlScalar(pin.source)}`,
310
+ `selector: ${yamlScalar(pin.selector, true)}`,
311
+ // How many nodes `selector` finds on a genuinely FRESH load of `url`. Seeded `pending`
312
+ // because nobody has checked yet — the ticket is written on the same synchronous tick as
313
+ // the click, and the only honest fresh load is the screenshot's, which happens after.
314
+ // shot.mjs replaces this with an integer when the shutter fires (› recordSelectorMatches).
315
+ // It stays `pending` when shots are off or the screenshot failed, which is the truth: the
316
+ // selector was never tested. It is never silently dropped, because silence is the defect
317
+ // this field exists to end — a selector anchored on a framework-generated id resolves to
318
+ // exactly one node when written and none ever again, and every other part of the ticket
319
+ // still looks perfect.
320
+ `selector_matches: ${pin.selectorMatches ?? "pending"}`,
321
+ `url: ${yamlScalar(pin.url)}`,
322
+ `viewport: ${flowNums(pin.viewport, ["width", "height", "dpr"])}`,
323
+ `scroll: ${flowNums(pin.scroll, ["x", "y"])}`,
324
+ `rect: ${flowNums(pin.rect, ["x", "y", "width", "height"])}`,
325
+ `shot: ${pin.shot}`,
326
+ `outer_html: ${yamlScalar(pin.outerHTML, true)}`,
327
+ "```",
328
+ "",
329
+ ].join("\n");
330
+ }
331
+
332
+ /** Reject a payload that cannot make a valid ticket. Refuses; never repairs. */
333
+ export function validate(payload) {
334
+ const p = payload ?? {};
335
+ if (typeof p.comment !== "string" || p.comment.trim() === "") return "the comment is empty";
336
+ if (p.dispatch !== "now" && p.dispatch !== "queue") return `dispatch must be "now" or "queue", got ${JSON.stringify(p.dispatch)}`;
337
+ if (typeof p.selector !== "string" || p.selector.trim() === "") return "the selector is missing";
338
+ if (typeof p.url !== "string" || !p.url.startsWith("/")) return "the url must be a site path starting with /";
339
+ for (const [key, fields] of [["viewport", ["width", "height", "dpr"]], ["scroll", ["x", "y"]], ["rect", ["x", "y", "width", "height"]]]) {
340
+ const o = p[key];
341
+ if (!o || typeof o !== "object") return `${key} is missing`;
342
+ for (const f of fields) if (!Number.isFinite(Number(o[f]))) return `${key}.${f} is not a number`;
343
+ }
344
+ if (Number(p.rect.width) <= 0 || Number(p.rect.height) <= 0) return "the clicked element has no width or height to shoot";
345
+ return null;
346
+ }
347
+
348
+ /**
349
+ * Allocate, render and write one ticket. Atomic: the markdown is written to a temp file beside
350
+ * its destination and renamed, so a reader never sees half a ticket, and the destination is
351
+ * refused if it already exists (a lost race re-allocates rather than overwriting the comment).
352
+ *
353
+ * @returns {{ id: string, num: number, file: string, shot: string, archiveName: string, absolute: string }}
354
+ * every path repo-relative except `absolute`, which never leaves this process.
355
+ */
356
+ export function writeTicket(repoRoot, payload, { backlogDir = "backlog" } = {}) {
357
+ const bad = validate(payload);
358
+ if (bad) throw new Error(bad);
359
+
360
+ const tasksDir = join(repoRoot, backlogDir, "tasks");
361
+ mkdirSync(tasksDir, { recursive: true });
362
+
363
+ const title = titleFrom(payload.comment);
364
+ const slug = slugify(title);
365
+ const createdDate = new Date().toLocaleDateString("en-CA"); // local YYYY-MM-DD, not UTC
366
+
367
+ for (let attempt = 0; attempt < 8; attempt++) {
368
+ const num = nextNumber(tasksDir) + attempt;
369
+ const base = `pin-${pad(num)}-${slug}.md`;
370
+ const absolute = join(tasksDir, base);
371
+ if (existsSync(absolute)) continue;
372
+ // A same-number file under a DIFFERENT slug must lose the race too, or two tickets share an id.
373
+ if (readdirSync(tasksDir).some((n) => n.startsWith(`pin-${pad(num)}-`) || n === `pin-${pad(num)}.md`)) continue;
374
+
375
+ const id = `PIN-${pad(num)}`;
376
+ const shot = `${backlogDir}/assets/pin-${pad(num)}.png`;
377
+ const body = renderTicket({
378
+ id,
379
+ num,
380
+ title,
381
+ createdDate,
382
+ labels: labelsFor(payload.source),
383
+ comment: payload.comment,
384
+ pin: {
385
+ dispatch: payload.dispatch,
386
+ source: payload.source || "unresolved",
387
+ selector: payload.selector,
388
+ selectorMatches: payload.selectorMatches,
389
+ url: payload.url,
390
+ viewport: payload.viewport,
391
+ scroll: payload.scroll,
392
+ rect: payload.rect,
393
+ shot,
394
+ outerHTML: String(payload.outerHTML ?? "").slice(0, 400),
395
+ },
396
+ });
397
+
398
+ const tmp = join(tasksDir, `.${base}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`);
399
+ try {
400
+ writeFileSync(tmp, body, { encoding: "utf8", flag: "wx" });
401
+ renameSync(tmp, absolute);
402
+ } catch (e) {
403
+ rmSync(tmp, { force: true });
404
+ throw e;
405
+ }
406
+ return { id, num, file: `${backlogDir}/tasks/${base}`, shot, archiveName: `pin-${pad(num)}.png`, absolute };
407
+ }
408
+ throw new Error("orbytes-pin: could not allocate a free ticket number after 8 tries");
409
+ }
410
+
411
+ /**
412
+ * Write the fresh-load selector count into a ticket that is already on disk.
413
+ *
414
+ * Surgical by design: it rewrites exactly the one `selector_matches:` line inside the fenced pin
415
+ * block and touches nothing else — not the frontmatter backlog.md owns, not the comment, not
416
+ * the image. Atomic, same temp-and-rename as writeTicket, so a reader never sees half a ticket.
417
+ *
418
+ * Refuses rather than warns (behaviour.md — a guard that warns instead of refusing is not a
419
+ * guard): no fence, no seeded line, or a file that moved between read and write, and this throws
420
+ * with the path in the message. The caller turns that into a visible failure; it must never
421
+ * become a ticket that quietly keeps saying `pending` while claiming to have been checked.
422
+ *
423
+ * @param {string} absolute absolute path of the ticket file
424
+ * @param {number} count nodes the ticket's selector matched on a fresh load
425
+ * @returns {{ file: string, matches: number }}
426
+ */
427
+ export function recordSelectorMatches(absolute, count) {
428
+ const n = Number(count);
429
+ if (!Number.isInteger(n) || n < 0) throw new Error(`selector_matches must be a whole number, got ${JSON.stringify(count)}`);
430
+
431
+ const text = readFileSync(absolute, "utf8");
432
+ const fence = text.indexOf(PIN_FENCE);
433
+ if (fence === -1) throw new Error(`${absolute} has no ${PIN_FENCE} block to record selector_matches in`);
434
+ const end = text.indexOf("\n```", fence + PIN_FENCE.length);
435
+ if (end === -1) throw new Error(`${absolute} has an unterminated ${PIN_FENCE} block`);
436
+
437
+ const head = text.slice(0, fence);
438
+ const block = text.slice(fence, end);
439
+ const tail = text.slice(end);
440
+ if (!/^selector_matches:.*$/m.test(block)) {
441
+ throw new Error(`${absolute} has no selector_matches line in its pin block — it predates the field, or was rewritten`);
442
+ }
443
+ const updated = head + block.replace(/^selector_matches:.*$/m, `selector_matches: ${n}`) + tail;
444
+
445
+ const tmp = `${absolute}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
446
+ try {
447
+ writeFileSync(tmp, updated, { encoding: "utf8", flag: "wx" });
448
+ renameSync(tmp, absolute);
449
+ } catch (e) {
450
+ rmSync(tmp, { force: true });
451
+ throw e;
452
+ }
453
+ return { file: absolute, matches: n };
454
+ }
455
+
456
+ // ── Reading ────────────────────────────────────────────────────────────────
457
+ // A deliberately small parser for the shape renderTicket writes AND for what backlog.md rewrites
458
+ // it into: top-level scalars, inline flow arrays and maps, block sequences and block scalars, in
459
+ // the frontmatter and inside the fenced pin block. It is not a general YAML implementation —
460
+ // anchors, tags, multi-document streams and nested block maps are all out of scope.
461
+
462
+ const unquote = (s) => {
463
+ const t = s.trim();
464
+ if (t.startsWith("'") && t.endsWith("'") && t.length > 1) return t.slice(1, -1).replace(/''/g, "'");
465
+ if (t.startsWith('"') && t.endsWith('"') && t.length > 1) return t.slice(1, -1);
466
+ return t;
467
+ };
468
+
469
+ function coerce(raw) {
470
+ const t = raw.trim();
471
+ if (t.startsWith("[") && t.endsWith("]")) {
472
+ const inner = t.slice(1, -1).trim();
473
+ return inner ? inner.split(",").map((v) => unquote(v)) : [];
474
+ }
475
+ if (t.startsWith("{") && t.endsWith("}")) {
476
+ const out = {};
477
+ for (const part of t.slice(1, -1).split(",")) {
478
+ const i = part.indexOf(":");
479
+ if (i === -1) continue;
480
+ const v = unquote(part.slice(i + 1));
481
+ out[part.slice(0, i).trim()] = /^-?\d+(\.\d+)?$/.test(v) ? Number(v) : v;
482
+ }
483
+ return out;
484
+ }
485
+ return unquote(t);
486
+ }
487
+
488
+ /** A block-scalar header and nothing else: `>`, `>-`, `|`, `|+`, `|2-`, `|-2`. */
489
+ const BLOCK_HEADER = /^([|>])((?:[-+]\d?)|(?:\d[-+]?)|)$/;
490
+
491
+ /**
492
+ * A YAML block scalar's value: the indented lines captured under a `>` / `|` header.
493
+ *
494
+ * backlog.md rewrites a long `title` into this shape on any write — including the status change
495
+ * made by dragging a card — so this is the ordinary reading path for a real ticket, not a
496
+ * hand-edit allowance. Read as a plain scalar, the header alone parses to the literal string
497
+ * ">-" and every continuation line is dropped, which is unrecoverable: the title is gone from
498
+ * the parser's output entirely.
499
+ *
500
+ * Verified against real folded output. Three details are easy to get wrong and are all handled
501
+ * here deliberately:
502
+ * · the explicit-indent forms `|2-` and `|-2` are both accepted, because YAML allows either
503
+ * order, and the indent is read from either.
504
+ * · when an explicit indent IS given, exactly that many columns are stripped, rather than the
505
+ * minimum indentation of the block — which is the whole reason the indicator exists, since
506
+ * it is what preserves leading spaces on the first line.
507
+ * · in a folded block, N consecutive line breaks fold to N-1 newlines, so one blank line is
508
+ * one newline, and a run of two or more blanks does not gain an extra. Interior runs of
509
+ * spaces survive rather than collapsing.
510
+ *
511
+ * A more-indented line inside a folded block is never folded into its neighbours — a real YAML
512
+ * rule, cheap to honour, and the one shape where folding silently destroys structure.
513
+ *
514
+ * The chomping indicator is parsed and then ignored: it decides only the trailing newline, nothing downstream renders one, and a title that came back
515
+ * ending in "\n" would be correct YAML and a nuisance everywhere it is printed.
516
+ */
517
+ function blockScalar(rawLines, style, explicitIndent, parentIndent) {
518
+ const indents = rawLines.filter((l) => l.trim() !== "").map((l) => /^[ \t]*/.exec(l)[0].length);
519
+ const strip = explicitIndent > 0 ? parentIndent + explicitIndent : indents.length ? Math.min(...indents) : 0;
520
+ const body = rawLines.map((l) => l.slice(strip));
521
+ while (body.length && body.at(-1).trim() === "") body.pop();
522
+ if (style === "|") return body.join("\n");
523
+
524
+ let out = "";
525
+ let breaks = 0;
526
+ let prevMore = false;
527
+ for (const line of body) {
528
+ if (line.trim() === "") {
529
+ breaks++;
530
+ continue;
531
+ }
532
+ const more = /^[ \t]/.test(line);
533
+ const text = more ? line.replace(/[ \t]+$/, "") : line.trim();
534
+ if (out === "") out = text;
535
+ else out += (breaks > 0 ? "\n".repeat(breaks) : more || prevMore ? "\n" : " ") + text;
536
+ breaks = 0;
537
+ prevMore = more;
538
+ }
539
+ return out;
540
+ }
541
+
542
+ /**
543
+ * One YAML mapping, as far as a ticket ever needs: `key: <flow scalar>`, `key:` over a block
544
+ * sequence, and `key:` over a block scalar.
545
+ *
546
+ * `anchored` is the difference between the two call sites and is not cosmetic. Frontmatter keys
547
+ * must start at column 0, so an indented line can only ever be a continuation — that is what
548
+ * stops a nested map's child keys being hoisted to the top level. Inside the pin fence, keys are
549
+ * matched after trimming, which is what the previous reader did and what keeps an indented
550
+ * hand-edit readable.
551
+ *
552
+ * @param {string[]} lines
553
+ * @param {{ anchored: boolean }} opts
554
+ */
555
+ function parseMapping(lines, { anchored }) {
556
+ const out = {};
557
+ for (let i = 0; i < lines.length; i++) {
558
+ const raw = lines[i];
559
+ if (!raw.trim() || raw.trim().startsWith("#")) continue;
560
+ const kv = /^([A-Za-z_][\w]*):\s?(.*)$/.exec(anchored ? raw : raw.trim());
561
+ if (!kv) continue;
562
+ const [, key, rest] = kv;
563
+
564
+ // A block-scalar header is not a value: the value is the indented block beneath it. Tested
565
+ // BEFORE the plain-scalar branch, which would otherwise store ">-" and drop every line of
566
+ // the actual text.
567
+ const header = BLOCK_HEADER.exec(rest.trim());
568
+ if (header) {
569
+ const kept = [];
570
+ while (i + 1 < lines.length && (lines[i + 1].trim() === "" || /^[ \t]/.test(lines[i + 1]))) {
571
+ kept.push(lines[++i]);
572
+ }
573
+ const digit = /\d/.exec(header[2]);
574
+ out[key] = blockScalar(kept, header[1], digit ? Number(digit[0]) : 0, /^[ \t]*/.exec(raw)[0].length);
575
+ continue;
576
+ }
577
+
578
+ if (rest.trim() !== "") {
579
+ out[key] = coerce(rest);
580
+ continue;
581
+ }
582
+
583
+ // Nothing after the colon: a block sequence, or an empty value. backlog.md rewrites the
584
+ // inline `labels: [pin, hero]` this module writes into a `- pin` / `- hero` list on the same
585
+ // write that folds the title (measured 2026-09-21 against backlog.md 1.52.0), so this is the
586
+ // ordinary shape of a touched ticket, not a hand-edit allowance.
587
+ const items = [];
588
+ while (i + 1 < lines.length && /^\s+-\s/.test(lines[i + 1])) {
589
+ items.push(unquote(lines[++i].replace(/^\s+-\s/, "")));
590
+ }
591
+ out[key] = items.length ? items : "";
592
+ }
593
+ return out;
594
+ }
595
+
596
+ /**
597
+ * Parse one ticket: backlog.md's frontmatter (status, title, labels, priority…) plus the body —
598
+ * the pin comment and the fenced `yaml pin` block that carries every field this package owns.
599
+ *
600
+ * The pin fields come from the FENCE, never from frontmatter, because backlog strips unknown
601
+ * frontmatter keys on any write (see renderTicket). A ticket whose fence is missing or
602
+ * unparseable is a hard error naming the file, not a half-filled object: a pin with an empty
603
+ * rect would send a screenshot to the wrong place and look like it worked.
604
+ *
605
+ * `status` comes back RAW — exactly the string on disk, which on a ticket written before
606
+ * 2026-09-22 is `Done`. This is the file reader, and a reader that silently improved a value
607
+ * would leave nothing able to tell you what the file actually says. Put it through
608
+ * `normaliseStatus()` before comparing it to anything, or ask `isOpen()`; never `=== "Resolved"`.
609
+ *
610
+ * @param {string} text
611
+ * @param {string} [file] repo-relative path, used in the error message
612
+ */
613
+ export function parseTicket(text, file = "<unknown file>") {
614
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
615
+ if (!m) throw new Error(`orbytes-pin: ${file} has no frontmatter — not a ticket.`);
616
+
617
+ // Frontmatter, in BOTH the shapes a ticket can be in: what this module writes (inline flow
618
+ // arrays, a bare `title`), and what backlog.md rewrites that into the first time anyone moves
619
+ // the card — `labels: [pin, hero]` becomes a `- pin` / `- hero` list, a title over 78 characters
620
+ // becomes a `>-` block, and `updated_date` appears. A reader that handles only the first shape
621
+ // loses `labels` and the real `title` on every ticket that has been touched on the board.
622
+ const front = parseMapping(m[1].split(/\r?\n/), { anchored: true });
623
+
624
+ const body = m[2];
625
+ const fence = body.indexOf(PIN_FENCE);
626
+ if (fence === -1) {
627
+ throw new Error(`orbytes-pin: ${file} has no \`\`\`yaml pin block — its pin data was stripped, or it was not written by this package.`);
628
+ }
629
+ const afterOpen = body.indexOf("\n", fence) + 1;
630
+ const closeAt = body.indexOf("\n```", afterOpen);
631
+ if (afterOpen === 0 || closeAt === -1) {
632
+ throw new Error(`orbytes-pin: ${file} has an unterminated \`\`\`yaml pin block.`);
633
+ }
634
+
635
+ // The same reader as the frontmatter. backlog never rewrites the body, so the fence holds what
636
+ // this module wrote — but one reader means a hand-edited block scalar in here cannot become a
637
+ // second, quieter version of the bug the frontmatter just had.
638
+ const pin = parseMapping(body.slice(afterOpen, closeAt).split(/\r?\n/), { anchored: false });
639
+ for (const required of ["dispatch", "source", "selector", "url", "viewport", "scroll", "rect", "shot"]) {
640
+ if (pin[required] === undefined) {
641
+ throw new Error(`orbytes-pin: ${file} is missing \`${required}\` from its yaml pin block.`);
642
+ }
643
+ }
644
+ // `selector_matches` is a COUNT, so hand it back as one. coerce() returns every top-level
645
+ // scalar as a string, and the string "0" is truthy — a reader writing the obvious
646
+ // `if (!t.pin.selector_matches)` would treat a dead selector as a healthy one, which is the
647
+ // exact silence this field exists to end. `pending` (never tested) and a ticket written
648
+ // before the field existed (undefined) both stay as they are: neither is a number, and
649
+ // neither may be rounded to one.
650
+ if (typeof pin.selector_matches === "string" && /^\d+$/.test(pin.selector_matches)) {
651
+ pin.selector_matches = Number(pin.selector_matches);
652
+ }
653
+
654
+ // The pin comment: everything under `## Description` up to the image or the fence, whichever
655
+ // comes first. A heading of any other name means backlog rendered nothing, so refuse it.
656
+ const heading = /^##[ \t]+Description[ \t]*$/m.exec(body);
657
+ if (!heading) throw new Error(`orbytes-pin: ${file} has no "## Description" heading — backlog.md renders no other.`);
658
+ const rest = body.slice(heading.index + heading[0].length);
659
+ const stop = Math.min(
660
+ ...[rest.indexOf(PIN_FENCE), rest.search(/^!\[\]\(/m)].filter((i) => i >= 0).concat([rest.length]),
661
+ );
662
+ return { ...front, pin, comment: rest.slice(0, stop).trim() };
663
+ }
664
+
665
+ /**
666
+ * Every LIVE ticket in `<repo>/<backlogDir>/tasks`, oldest number first.
667
+ *
668
+ * This is the agent-facing reader — what a session calls to find out what there is to do — so
669
+ * `Cancelled` tickets are **not in it**. Ruled 2026-09-22, on that status: it is just an archive
670
+ * for cancelled work, and items in here must not override anything or take up context space for
671
+ * an agent. Filtering here rather than in each caller is what makes that true by default: an agent
672
+ * that never heard of the rule still cannot be handed a dead ticket, and one that wants the
673
+ * archive has to say so in the call.
674
+ *
675
+ * `includeCancelled: true` returns them, and the only callers that pass it are the two surfaces
676
+ * that draw the archive — `/pin` and `orbytes-pin-gallery` — plus `updateTicket`, which has to be able to
677
+ * find a cancelled ticket in order to move it back out.
678
+ *
679
+ * Statuses come back RAW (› `parseTicket`), so the filter normalises before it compares: a ticket
680
+ * whose file still says `Done` is `Resolved`, and is live.
681
+ *
682
+ * @param {string} repoRoot
683
+ * @param {{ backlogDir?: string, includeCancelled?: boolean }} [options]
684
+ */
685
+ export function listTickets(repoRoot, { backlogDir = "backlog", includeCancelled = false } = {}) {
686
+ const tasksDir = join(repoRoot, backlogDir, "tasks");
687
+ if (!existsSync(tasksDir)) return [];
688
+ return readdirSync(tasksDir)
689
+ .filter((n) => /^pin-\d+.*\.md$/.test(n))
690
+ .sort()
691
+ .map((name) => {
692
+ const file = `${backlogDir}/tasks/${name}`;
693
+ // Not swallowed: a ticket this package cannot read is a defect to see, not a row to drop.
694
+ return { ...parseTicket(readFileSync(join(tasksDir, name), "utf8"), file), file };
695
+ })
696
+ .filter((t) => includeCancelled || !isCancelled(t.status));
697
+ }