@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.
- package/LICENSE +37 -0
- package/README.md +410 -0
- package/bin/lab-cull.mjs +401 -0
- package/bin/pin-gallery.mjs +121 -0
- package/defaults.mjs +120 -0
- package/dist/core/astro-integration.js +130 -0
- package/dist/core/index.js +6 -0
- package/dist/core/lib-paths.js +29 -0
- package/dist/core/options.js +173 -0
- package/dist/core/utils/get-exports.js +52 -0
- package/dist/core/utils/invariant.js +12 -0
- package/dist/core/utils/kebab-case.js +15 -0
- package/dist/core/utils/path-builder.js +22 -0
- package/dist/core/utils/path.js +53 -0
- package/dist/core/virtual-module/get-story-modules.js +60 -0
- package/dist/core/virtual-module/story-modules.js +8 -0
- package/dist/core/virtual-module/virtual-module-ids.js +22 -0
- package/dist/core/virtual-module/virtual-routes.js +83 -0
- package/dist/core/virtual-module/vite-plugin.js +98 -0
- package/docs/PIN-CONTRACT.md +263 -0
- package/docs/PIN.md +429 -0
- package/index.d.ts +279 -0
- package/index.mjs +347 -0
- package/package.json +93 -0
- package/src/Empty.astro +4 -0
- package/src/Home.astro +298 -0
- package/src/LabHead.astro +1102 -0
- package/src/core/LICENSE-astrobook +166 -0
- package/src/core/astro-integration.ts +166 -0
- package/src/core/client.ts +89 -0
- package/src/core/index.ts +7 -0
- package/src/core/lib/components/empty.astro +1 -0
- package/src/core/lib/components/head.astro +1 -0
- package/src/core/lib/components/home.astro +8 -0
- package/src/core/lib/components/with-decorators.astro +22 -0
- package/src/core/lib/pages/app.astro +19 -0
- package/src/core/lib/pages/preview.astro +17 -0
- package/src/core/lib/pages/story.astro +16 -0
- package/src/core/lib-paths.ts +72 -0
- package/src/core/options.ts +262 -0
- package/src/core/utils/get-exports.ts +59 -0
- package/src/core/utils/invariant.ts +13 -0
- package/src/core/utils/kebab-case.ts +30 -0
- package/src/core/utils/path-builder.ts +45 -0
- package/src/core/utils/path.ts +80 -0
- package/src/core/virtual-module/get-story-modules.ts +110 -0
- package/src/core/virtual-module/story-modules.ts +9 -0
- package/src/core/virtual-module/virtual-module-ids.ts +17 -0
- package/src/core/virtual-module/virtual-routes.ts +130 -0
- package/src/core/virtual-module/vite-plugin.ts +125 -0
- package/src/pin/board.mjs +1521 -0
- package/src/pin/index.mjs +666 -0
- package/src/pin/shot.mjs +427 -0
- package/src/pin/source-stamp.mjs +159 -0
- package/src/pin/tickets.mjs +697 -0
- package/src/pin/toolbar.js +3181 -0
- package/src/shell/Browse.astro +371 -0
- package/src/shell/CardGrid.astro +297 -0
- package/src/shell/Viewport.astro +1330 -0
- package/src/shell/index.json.ts +12 -0
- package/src/shell/lab-index.ts +344 -0
- package/src/shell/lab-params.ts +245 -0
- package/src/shell/live-files.mjs +164 -0
- package/src/shell/marks.mjs +136 -0
- package/src/types/index.ts +6 -0
- package/src/types/types.ts +239 -0
- package/src/types/virtual.d.ts +29 -0
- package/src/ui/components/app.astro +13 -0
- package/src/ui/components/build-path.ts +13 -0
- package/src/ui/components/build-tree.ts +108 -0
- package/src/ui/components/collapse-duration.ts +28 -0
- package/src/ui/components/compress-terms.ts +10 -0
- package/src/ui/components/dashboard-layout.astro +39 -0
- package/src/ui/components/home.astro +65 -0
- package/src/ui/components/layout.astro +110 -0
- package/src/ui/components/preview-layout.astro +109 -0
- package/src/ui/components/sidebar-button-fullscreen.astro +38 -0
- package/src/ui/components/sidebar-button-search.astro +23 -0
- package/src/ui/components/sidebar-button-theme.astro +9 -0
- package/src/ui/components/sidebar-button.astro +24 -0
- package/src/ui/components/sidebar-resize-handle.astro +74 -0
- package/src/ui/components/sidebar-search-panel.astro +41 -0
- package/src/ui/components/sidebar-search-script.ts +103 -0
- package/src/ui/components/sidebar-title.astro +17 -0
- package/src/ui/components/sidebar-tree-node.astro +143 -0
- package/src/ui/components/sidebar-tree.astro +84 -0
- package/src/ui/components/sidebar.astro +29 -0
- package/src/ui/components/theme-message.ts +26 -0
- package/src/ui/components/theme-script.astro +71 -0
- package/src/ui/components/theme-toggle.astro +63 -0
- package/src/ui/components/theme.ts +32 -0
- package/src/ui/index.ts +4 -0
- package/src/ui/lab.css +549 -0
- 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
|
+
``,
|
|
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
|
+
}
|