@picoai/tickets 0.1.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/.tickets/spec/AGENTS_EXAMPLE.md +16 -0
- package/.tickets/spec/TICKETS.md +469 -0
- package/.tickets/spec/version/20260205-tickets-spec.md +34 -0
- package/.tickets/spec/version/PROPOSED-tickets-spec.md +15 -0
- package/LICENSE +201 -0
- package/README.md +228 -0
- package/bin/tickets.js +5 -0
- package/package.json +39 -0
- package/src/cli.js +1488 -0
- package/src/lib/constants.js +7 -0
- package/src/lib/listing.js +85 -0
- package/src/lib/repair.js +338 -0
- package/src/lib/util.js +146 -0
- package/src/lib/validation.js +482 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const BASE_DIR = ".tickets/spec";
|
|
2
|
+
export const FORMAT_VERSION = 1;
|
|
3
|
+
export const FORMAT_VERSION_URL = "version/20260205-tickets-spec.md";
|
|
4
|
+
|
|
5
|
+
export const STATUS_VALUES = ["todo", "doing", "blocked", "done", "canceled"];
|
|
6
|
+
export const PRIORITY_VALUES = ["low", "medium", "high", "critical"];
|
|
7
|
+
export const ASSIGNMENT_MODE_VALUES = ["human_only", "agent_only", "mixed"];
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { readJsonl } from "./util.js";
|
|
5
|
+
import { collectTicketPaths, validateTicket } from "./validation.js";
|
|
6
|
+
|
|
7
|
+
export function listTickets(filters) {
|
|
8
|
+
const rows = [];
|
|
9
|
+
|
|
10
|
+
for (const ticketPath of collectTicketPaths(null)) {
|
|
11
|
+
const [, frontMatter] = validateTicket(ticketPath);
|
|
12
|
+
if (!frontMatter || Object.keys(frontMatter).length === 0) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (!passesFilters(frontMatter, filters)) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
rows.push({
|
|
20
|
+
id: frontMatter.id ?? "",
|
|
21
|
+
title: frontMatter.title ?? "",
|
|
22
|
+
status: frontMatter.status ?? "",
|
|
23
|
+
priority: frontMatter.priority ?? "",
|
|
24
|
+
owner: frontMatter.assignment?.owner,
|
|
25
|
+
mode: frontMatter.assignment?.mode,
|
|
26
|
+
last_updated: lastUpdated(path.dirname(ticketPath)),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return rows;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function passesFilters(frontMatter, filters) {
|
|
34
|
+
if (filters.status && frontMatter.status !== filters.status) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
if (filters.priority && frontMatter.priority !== filters.priority) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (filters.mode && frontMatter.assignment?.mode !== filters.mode) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if (filters.owner && frontMatter.assignment?.owner !== filters.owner) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if (filters.label) {
|
|
47
|
+
const labels = Array.isArray(frontMatter.labels) ? frontMatter.labels : [];
|
|
48
|
+
if (!labels.includes(filters.label)) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (filters.text) {
|
|
53
|
+
const text = String(filters.text).toLowerCase();
|
|
54
|
+
const haystack = `${frontMatter.title ?? ""}\n${frontMatter.description ?? ""}`.toLowerCase();
|
|
55
|
+
if (!haystack.includes(text)) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function lastUpdated(ticketDir) {
|
|
63
|
+
const logsDir = path.join(ticketDir, "logs");
|
|
64
|
+
let latest = "";
|
|
65
|
+
|
|
66
|
+
if (!fs.existsSync(logsDir)) {
|
|
67
|
+
return latest;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const logFiles = fs
|
|
71
|
+
.readdirSync(logsDir, { withFileTypes: true })
|
|
72
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
73
|
+
.map((entry) => path.join(logsDir, entry.name));
|
|
74
|
+
|
|
75
|
+
for (const logFile of logFiles) {
|
|
76
|
+
for (const entry of readJsonl(logFile)) {
|
|
77
|
+
const ts = entry.ts;
|
|
78
|
+
if (typeof ts === "string" && (latest === "" || ts > latest)) {
|
|
79
|
+
latest = ts;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return latest;
|
|
85
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import readline from "node:readline/promises";
|
|
3
|
+
|
|
4
|
+
import yaml from "yaml";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
FORMAT_VERSION,
|
|
8
|
+
FORMAT_VERSION_URL,
|
|
9
|
+
PRIORITY_VALUES,
|
|
10
|
+
} from "./constants.js";
|
|
11
|
+
import {
|
|
12
|
+
iso8601,
|
|
13
|
+
loadTicket,
|
|
14
|
+
newUuidv7,
|
|
15
|
+
nowUtc,
|
|
16
|
+
parseIso,
|
|
17
|
+
writeTicket,
|
|
18
|
+
} from "./util.js";
|
|
19
|
+
|
|
20
|
+
export function loadIssuesFile(filePath) {
|
|
21
|
+
return yaml.parse(fs.readFileSync(filePath, "utf8")) ?? {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function applyRepairs(repairs, options = {}) {
|
|
25
|
+
const includeOptional = options.includeOptional ?? false;
|
|
26
|
+
const nonInteractive = options.nonInteractive ?? false;
|
|
27
|
+
const applied = [];
|
|
28
|
+
|
|
29
|
+
for (const repair of repairs) {
|
|
30
|
+
if (repair.optional && !includeOptional) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (!repair.enabled) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const action = repair.action;
|
|
38
|
+
const params = repair.params ?? {};
|
|
39
|
+
const ticketPath = repair.ticket_path;
|
|
40
|
+
|
|
41
|
+
if (action === "set_front_matter_field") {
|
|
42
|
+
const field = params.field;
|
|
43
|
+
let value = params.value;
|
|
44
|
+
if (value == null && params.generate_uuidv7) {
|
|
45
|
+
value = newUuidv7();
|
|
46
|
+
}
|
|
47
|
+
if (value == null) {
|
|
48
|
+
if (nonInteractive) {
|
|
49
|
+
throw new Error(`Repair needs value for ${field}`);
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`Interactive value required for ${field}`);
|
|
52
|
+
}
|
|
53
|
+
setFrontMatterField(ticketPath, field, value);
|
|
54
|
+
applied.push(`${ticketPath}: set ${field}`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (action === "add_sections") {
|
|
59
|
+
addMissingSections(ticketPath);
|
|
60
|
+
applied.push(`${ticketPath}: added missing sections`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (action === "normalize_created_at") {
|
|
65
|
+
normalizeCreatedAt(ticketPath);
|
|
66
|
+
applied.push(`${ticketPath}: normalized created_at`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (action === "normalize_labels") {
|
|
71
|
+
normalizeLabels(ticketPath);
|
|
72
|
+
applied.push(`${ticketPath}: normalized labels`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (action === "set_assignment_owner") {
|
|
77
|
+
setAssignmentOwner(ticketPath, params.value ?? null);
|
|
78
|
+
applied.push(`${ticketPath}: set assignment.owner`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (action === "reset_verification_commands") {
|
|
83
|
+
resetVerificationCommands(ticketPath, Array.isArray(params.commands) ? params.commands : []);
|
|
84
|
+
applied.push(`${ticketPath}: reset verification.commands`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (action === "normalize_verification_commands") {
|
|
89
|
+
normalizeVerificationCommands(ticketPath);
|
|
90
|
+
applied.push(`${ticketPath}: normalized verification.commands`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (nonInteractive) {
|
|
95
|
+
throw new Error(`Unsupported repair action ${action}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return applied;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function runInteractive(repairs, options = {}) {
|
|
103
|
+
const includeOptional = options.includeOptional ?? false;
|
|
104
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
105
|
+
throw new Error("Interactive mode requires a TTY");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
109
|
+
const prepared = [];
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
for (const repair of repairs) {
|
|
113
|
+
if (repair.optional && !includeOptional) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const [description, suggested] = describeRepair(repair);
|
|
118
|
+
process.stdout.write(`\nRepair ${repair.id}: ${description}\n`);
|
|
119
|
+
const applyChoice = await promptYesNo(rl, "Apply this repair?", true);
|
|
120
|
+
if (!applyChoice) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
repair.enabled = true;
|
|
125
|
+
const action = repair.action;
|
|
126
|
+
const params = repair.params ?? {};
|
|
127
|
+
|
|
128
|
+
if (action === "set_front_matter_field") {
|
|
129
|
+
params.value = await promptValueForField(rl, params.field, repair.ticket_path, suggested);
|
|
130
|
+
} else if (action === "set_assignment_owner") {
|
|
131
|
+
params.value = await promptValueForField(rl, "assignment.owner", repair.ticket_path, suggested);
|
|
132
|
+
} else if (action === "reset_verification_commands") {
|
|
133
|
+
params.commands = await promptCommands(rl, Array.isArray(suggested) ? suggested : []);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
repair.params = params;
|
|
137
|
+
prepared.push(repair);
|
|
138
|
+
}
|
|
139
|
+
} finally {
|
|
140
|
+
rl.close();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return applyRepairs(prepared, { nonInteractive: true, includeOptional });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function describeRepair(repair) {
|
|
147
|
+
const action = repair.action;
|
|
148
|
+
const field = repair.params?.field;
|
|
149
|
+
const ticketPath = repair.ticket_path ?? "";
|
|
150
|
+
const value = repair.params?.value;
|
|
151
|
+
|
|
152
|
+
if (action === "add_sections") {
|
|
153
|
+
return [`Add missing required sections to ${ticketPath}.`, null];
|
|
154
|
+
}
|
|
155
|
+
if (action === "normalize_created_at") {
|
|
156
|
+
return [`Normalize created_at to ISO8601 UTC in ${ticketPath}.`, iso8601(nowUtc())];
|
|
157
|
+
}
|
|
158
|
+
if (action === "set_front_matter_field") {
|
|
159
|
+
if (field === "id") {
|
|
160
|
+
return ["Set ticket id to a valid UUIDv7 (used to identify the ticket).", newUuidv7()];
|
|
161
|
+
}
|
|
162
|
+
if (field === "version") {
|
|
163
|
+
return ["Set format version (integer, current 1).", FORMAT_VERSION];
|
|
164
|
+
}
|
|
165
|
+
if (field === "version_url") {
|
|
166
|
+
return ["Set version_url (path to the format definition for this version).", FORMAT_VERSION_URL];
|
|
167
|
+
}
|
|
168
|
+
if (field === "priority") {
|
|
169
|
+
return ["Set priority (low|medium|high|critical).", "medium"];
|
|
170
|
+
}
|
|
171
|
+
if (field === "labels") {
|
|
172
|
+
return ["Reset labels to a list of strings (comma-separated).", []];
|
|
173
|
+
}
|
|
174
|
+
return [`Set front matter field '${field}'.`, value];
|
|
175
|
+
}
|
|
176
|
+
if (action === "normalize_labels") {
|
|
177
|
+
return ["Normalize labels to strings, dropping invalid entries.", null];
|
|
178
|
+
}
|
|
179
|
+
if (action === "set_assignment_owner") {
|
|
180
|
+
return ["Set assignment.owner (who owns this ticket; freeform handle).", value];
|
|
181
|
+
}
|
|
182
|
+
if (action === "reset_verification_commands") {
|
|
183
|
+
return ["Set verification.commands (commands to verify acceptance).", value ?? []];
|
|
184
|
+
}
|
|
185
|
+
if (action === "normalize_verification_commands") {
|
|
186
|
+
return ["Normalize verification.commands to strings, dropping invalid entries.", null];
|
|
187
|
+
}
|
|
188
|
+
return ["Apply repair", value];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function promptYesNo(rl, message, defaultValue) {
|
|
192
|
+
const suffix = defaultValue ? " [Y/n] " : " [y/N] ";
|
|
193
|
+
while (true) {
|
|
194
|
+
const response = (await rl.question(`${message}${suffix}`)).trim().toLowerCase();
|
|
195
|
+
if (!response) {
|
|
196
|
+
return defaultValue;
|
|
197
|
+
}
|
|
198
|
+
if (["y", "yes"].includes(response)) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
if (["n", "no"].includes(response)) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
process.stdout.write("Please enter y or n.\n");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function promptValueForField(rl, field, ticketPath, defaultValue) {
|
|
209
|
+
let current = null;
|
|
210
|
+
if (ticketPath) {
|
|
211
|
+
try {
|
|
212
|
+
const [frontMatter] = loadTicket(ticketPath);
|
|
213
|
+
current = field === "assignment.owner" ? frontMatter.assignment?.owner : frontMatter[field];
|
|
214
|
+
} catch {
|
|
215
|
+
current = null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (field === "labels") {
|
|
220
|
+
const labels = Array.isArray(defaultValue)
|
|
221
|
+
? defaultValue
|
|
222
|
+
: Array.isArray(current)
|
|
223
|
+
? current
|
|
224
|
+
: [];
|
|
225
|
+
return promptLabels(rl, labels);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (field === "priority") {
|
|
229
|
+
const base = defaultValue ?? current ?? "medium";
|
|
230
|
+
while (true) {
|
|
231
|
+
const response = (await rl.question(`Priority [${base}]: `)).trim().toLowerCase();
|
|
232
|
+
if (!response) {
|
|
233
|
+
return base;
|
|
234
|
+
}
|
|
235
|
+
if (PRIORITY_VALUES.includes(response)) {
|
|
236
|
+
return response;
|
|
237
|
+
}
|
|
238
|
+
process.stdout.write(`Enter one of ${PRIORITY_VALUES.join(", ")}.\n`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const fallback = defaultValue ?? current;
|
|
243
|
+
const response = (await rl.question(`${field} [${fallback ?? ""}]: `)).trim();
|
|
244
|
+
if (!response && fallback != null) {
|
|
245
|
+
return fallback;
|
|
246
|
+
}
|
|
247
|
+
if (field === "id") {
|
|
248
|
+
return response || newUuidv7();
|
|
249
|
+
}
|
|
250
|
+
return response || fallback;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function promptLabels(rl, defaultLabels) {
|
|
254
|
+
const existing = defaultLabels.join(", ");
|
|
255
|
+
const response = (await rl.question(`Labels (comma-separated) [${existing}]: `)).trim();
|
|
256
|
+
if (!response) {
|
|
257
|
+
return defaultLabels;
|
|
258
|
+
}
|
|
259
|
+
return response
|
|
260
|
+
.split(",")
|
|
261
|
+
.map((v) => v.trim())
|
|
262
|
+
.filter(Boolean);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function promptCommands(rl, defaultCommands) {
|
|
266
|
+
const existing = defaultCommands.join("; ");
|
|
267
|
+
process.stdout.write("Enter verification commands (comma-separated). Leave blank to keep default.\n");
|
|
268
|
+
const response = (await rl.question(`Commands [${existing}]: `)).trim();
|
|
269
|
+
if (!response) {
|
|
270
|
+
return defaultCommands;
|
|
271
|
+
}
|
|
272
|
+
return response
|
|
273
|
+
.split(",")
|
|
274
|
+
.map((v) => v.trim())
|
|
275
|
+
.filter(Boolean);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function setFrontMatterField(ticketPath, field, value) {
|
|
279
|
+
const [frontMatter, body] = loadTicket(ticketPath);
|
|
280
|
+
frontMatter[field] = value;
|
|
281
|
+
writeTicket(ticketPath, frontMatter, body);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function addMissingSections(ticketPath) {
|
|
285
|
+
const [frontMatter, body] = loadTicket(ticketPath);
|
|
286
|
+
let nextBody = body;
|
|
287
|
+
for (const section of ["# Ticket", "## Description", "## Acceptance Criteria", "## Verification"]) {
|
|
288
|
+
if (!nextBody.includes(section)) {
|
|
289
|
+
nextBody += `\n${section}\n(fill in)\n`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
writeTicket(ticketPath, frontMatter, nextBody);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function normalizeCreatedAt(ticketPath) {
|
|
296
|
+
const [frontMatter, body] = loadTicket(ticketPath);
|
|
297
|
+
if (typeof frontMatter.created_at !== "string" || !parseIso(frontMatter.created_at)) {
|
|
298
|
+
frontMatter.created_at = iso8601(nowUtc());
|
|
299
|
+
writeTicket(ticketPath, frontMatter, body);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function normalizeLabels(ticketPath) {
|
|
304
|
+
const [frontMatter, body] = loadTicket(ticketPath);
|
|
305
|
+
const labels = Array.isArray(frontMatter.labels)
|
|
306
|
+
? frontMatter.labels.filter((v) => typeof v === "string")
|
|
307
|
+
: [];
|
|
308
|
+
frontMatter.labels = labels;
|
|
309
|
+
writeTicket(ticketPath, frontMatter, body);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function setAssignmentOwner(ticketPath, value) {
|
|
313
|
+
const [frontMatter, body] = loadTicket(ticketPath);
|
|
314
|
+
const assignment =
|
|
315
|
+
frontMatter.assignment && typeof frontMatter.assignment === "object" && !Array.isArray(frontMatter.assignment)
|
|
316
|
+
? frontMatter.assignment
|
|
317
|
+
: {};
|
|
318
|
+
assignment.owner = value;
|
|
319
|
+
frontMatter.assignment = assignment;
|
|
320
|
+
writeTicket(ticketPath, frontMatter, body);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function resetVerificationCommands(ticketPath, commands) {
|
|
324
|
+
const [frontMatter, body] = loadTicket(ticketPath);
|
|
325
|
+
frontMatter.verification = { commands };
|
|
326
|
+
writeTicket(ticketPath, frontMatter, body);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function normalizeVerificationCommands(ticketPath) {
|
|
330
|
+
const [frontMatter, body] = loadTicket(ticketPath);
|
|
331
|
+
const rawCommands = Array.isArray(frontMatter.verification?.commands)
|
|
332
|
+
? frontMatter.verification.commands
|
|
333
|
+
: [];
|
|
334
|
+
frontMatter.verification = {
|
|
335
|
+
commands: rawCommands.filter((v) => typeof v === "string"),
|
|
336
|
+
};
|
|
337
|
+
writeTicket(ticketPath, frontMatter, body);
|
|
338
|
+
}
|
package/src/lib/util.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import yaml from "yaml";
|
|
6
|
+
import { v7 as uuidv7, validate as uuidValidate, version as uuidVersion } from "uuid";
|
|
7
|
+
|
|
8
|
+
import { BASE_DIR } from "./constants.js";
|
|
9
|
+
|
|
10
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
11
|
+
|
|
12
|
+
export function repoRoot() {
|
|
13
|
+
return process.cwd();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function packageRoot() {
|
|
17
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
return path.resolve(here, "..", "..");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ticketsDir() {
|
|
22
|
+
return path.join(repoRoot(), ".tickets");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function baseDir() {
|
|
26
|
+
return path.join(repoRoot(), BASE_DIR);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function nowUtc() {
|
|
30
|
+
return new Date();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function iso8601(date) {
|
|
34
|
+
return date.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isoBasic(date) {
|
|
38
|
+
const year = date.getUTCFullYear();
|
|
39
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
40
|
+
const day = String(date.getUTCDate()).padStart(2, "0");
|
|
41
|
+
const hour = String(date.getUTCHours()).padStart(2, "0");
|
|
42
|
+
const minute = String(date.getUTCMinutes()).padStart(2, "0");
|
|
43
|
+
const second = String(date.getUTCSeconds()).padStart(2, "0");
|
|
44
|
+
const ms = String(date.getUTCMilliseconds()).padStart(3, "0");
|
|
45
|
+
return `${year}${month}${day}T${hour}${minute}${second}.${ms}Z`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseIso(value) {
|
|
49
|
+
if (typeof value !== "string") {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const ts = Date.parse(value);
|
|
53
|
+
return Number.isNaN(ts) ? null : new Date(ts);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function isUuidv7(value) {
|
|
57
|
+
if (typeof value !== "string" || !UUID_RE.test(value)) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return uuidValidate(value) && uuidVersion(value) === 7;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function newUuidv7() {
|
|
64
|
+
return uuidv7();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ensureDir(dirPath) {
|
|
68
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function readTemplate(relPath) {
|
|
72
|
+
const filePath = path.join(packageRoot(), relPath);
|
|
73
|
+
return fs.readFileSync(filePath, "utf8");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function loadTicket(ticketPath) {
|
|
77
|
+
const text = fs.readFileSync(ticketPath, "utf8");
|
|
78
|
+
if (!text.startsWith("---")) {
|
|
79
|
+
throw new Error("Missing front matter start '---'");
|
|
80
|
+
}
|
|
81
|
+
const match = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
82
|
+
if (!match) {
|
|
83
|
+
throw new Error("Malformed front matter");
|
|
84
|
+
}
|
|
85
|
+
const frontMatter = yaml.parse(match[1]) ?? {};
|
|
86
|
+
if (typeof frontMatter !== "object" || frontMatter === null || Array.isArray(frontMatter)) {
|
|
87
|
+
throw new Error("Front matter must be a mapping");
|
|
88
|
+
}
|
|
89
|
+
const body = match[2].replace(/^\n+/, "");
|
|
90
|
+
return [frontMatter, body];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function writeTicket(ticketPath, frontMatter, body) {
|
|
94
|
+
const yamlText = yaml.stringify(frontMatter).trimEnd();
|
|
95
|
+
const out = `---\n${yamlText}\n---\n${body.trimEnd()}\n`;
|
|
96
|
+
fs.writeFileSync(ticketPath, out);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function readJsonl(filePath) {
|
|
100
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
101
|
+
const entries = [];
|
|
102
|
+
for (const line of content.split(/\r?\n/)) {
|
|
103
|
+
const trimmed = line.trim();
|
|
104
|
+
if (!trimmed) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
entries.push(JSON.parse(trimmed));
|
|
108
|
+
}
|
|
109
|
+
return entries;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function appendJsonl(filePath, value) {
|
|
113
|
+
ensureDir(path.dirname(filePath));
|
|
114
|
+
fs.appendFileSync(filePath, `${JSON.stringify(value)}\n`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function listTicketDirs() {
|
|
118
|
+
const root = ticketsDir();
|
|
119
|
+
if (!fs.existsSync(root)) {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
return fs
|
|
123
|
+
.readdirSync(root, { withFileTypes: true })
|
|
124
|
+
.filter((entry) => entry.isDirectory())
|
|
125
|
+
.map((entry) => path.join(root, entry.name))
|
|
126
|
+
.sort((a, b) => a.localeCompare(b));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function matchesRunFilename(name) {
|
|
130
|
+
return /^[0-9T:.]+Z-[A-Za-z0-9_-]+\.jsonl$/.test(name);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function resolveTicketPath(ticketRef) {
|
|
134
|
+
const explicit = path.resolve(repoRoot(), ticketRef);
|
|
135
|
+
if (fs.existsSync(explicit)) {
|
|
136
|
+
if (fs.statSync(explicit).isDirectory()) {
|
|
137
|
+
return path.join(explicit, "ticket.md");
|
|
138
|
+
}
|
|
139
|
+
return explicit;
|
|
140
|
+
}
|
|
141
|
+
const candidate = path.join(ticketsDir(), ticketRef, "ticket.md");
|
|
142
|
+
if (fs.existsSync(candidate)) {
|
|
143
|
+
return candidate;
|
|
144
|
+
}
|
|
145
|
+
throw new Error(`Ticket not found: ${ticketRef}`);
|
|
146
|
+
}
|