@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/src/cli.js ADDED
@@ -0,0 +1,1488 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { Command } from "commander";
6
+ import yaml from "yaml";
7
+
8
+ import {
9
+ ASSIGNMENT_MODE_VALUES,
10
+ BASE_DIR,
11
+ FORMAT_VERSION,
12
+ FORMAT_VERSION_URL,
13
+ PRIORITY_VALUES,
14
+ STATUS_VALUES,
15
+ } from "./lib/constants.js";
16
+ import { listTickets } from "./lib/listing.js";
17
+ import { applyRepairs, loadIssuesFile, runInteractive } from "./lib/repair.js";
18
+ import { collectTicketPaths, validateRunLog, validateTicket } from "./lib/validation.js";
19
+ import {
20
+ appendJsonl,
21
+ ensureDir,
22
+ iso8601,
23
+ isoBasic,
24
+ loadTicket,
25
+ newUuidv7,
26
+ nowUtc,
27
+ readTemplate,
28
+ repoRoot,
29
+ resolveTicketPath,
30
+ ticketsDir,
31
+ writeTicket,
32
+ } from "./lib/util.js";
33
+
34
+ function collectOption(value, previous = []) {
35
+ previous.push(value);
36
+ return previous;
37
+ }
38
+
39
+ function hasErrors(issues) {
40
+ return issues.some((issue) => issue.severity === "error");
41
+ }
42
+
43
+ function printIssues(issues) {
44
+ for (const issue of issues) {
45
+ const location = issue.ticket_path ?? issue.log ?? "";
46
+ process.stdout.write(`${String(issue.severity ?? "?").toUpperCase()}: ${issue.message} (${location})\n`);
47
+ }
48
+ }
49
+
50
+ function buildRepairsFromIssues(issues, options = {}) {
51
+ const includeOptional = options.includeOptional ?? false;
52
+ const autoEnableSafe = options.autoEnableSafe ?? false;
53
+ const repairs = [];
54
+ const seen = new Set();
55
+ const optionalCodes = new Set([
56
+ "PRIORITY_INVALID",
57
+ "LABELS_NOT_LIST",
58
+ "LABEL_INVALID_ENTRY",
59
+ "ASSIGNMENT_OWNER_INVALID",
60
+ "VERIFICATION_INVALID",
61
+ "VERIFICATION_COMMANDS_INVALID",
62
+ "VERIFICATION_COMMAND_INVALID",
63
+ ]);
64
+
65
+ for (const issue of issues) {
66
+ const code = issue.code;
67
+ const ticketPath = issue.ticket_path;
68
+ if (!ticketPath) {
69
+ continue;
70
+ }
71
+
72
+ const key = `${code}:${ticketPath}`;
73
+ if (seen.has(key)) {
74
+ continue;
75
+ }
76
+ seen.add(key);
77
+
78
+ const isOptional = optionalCodes.has(code);
79
+ if (isOptional && !includeOptional) {
80
+ continue;
81
+ }
82
+
83
+ const nextId = `R${String(repairs.length + 1).padStart(4, "0")}`;
84
+ const base = {
85
+ id: nextId,
86
+ enabled: false,
87
+ issue_ids: [issue.id ?? ""],
88
+ ticket_path: ticketPath,
89
+ };
90
+
91
+ if (code === "MISSING_SECTION") {
92
+ repairs.push({ ...base, safe: true, action: "add_sections", params: {}, optional: false });
93
+ } else if (["VERSION_MISSING", "VERSION_INVALID"].includes(code)) {
94
+ repairs.push({
95
+ ...base,
96
+ safe: true,
97
+ action: "set_front_matter_field",
98
+ params: { field: "version", value: FORMAT_VERSION },
99
+ optional: false,
100
+ });
101
+ } else if (["VERSION_URL_MISSING", "VERSION_URL_INVALID"].includes(code)) {
102
+ repairs.push({
103
+ ...base,
104
+ safe: true,
105
+ action: "set_front_matter_field",
106
+ params: { field: "version_url", value: FORMAT_VERSION_URL },
107
+ optional: false,
108
+ });
109
+ } else if (["CREATED_AT_INVALID", "MISSING_CREATED_AT"].includes(code)) {
110
+ repairs.push({ ...base, safe: true, action: "normalize_created_at", params: {}, optional: false });
111
+ } else if (["ID_NOT_UUIDV7", "MISSING_ID"].includes(code)) {
112
+ repairs.push({
113
+ ...base,
114
+ safe: false,
115
+ action: "set_front_matter_field",
116
+ params: { field: "id", value: null, generate_uuidv7: true, update_references: null },
117
+ optional: false,
118
+ });
119
+ } else if (code === "PRIORITY_INVALID") {
120
+ repairs.push({
121
+ ...base,
122
+ safe: true,
123
+ action: "set_front_matter_field",
124
+ params: { field: "priority", value: "medium" },
125
+ optional: true,
126
+ });
127
+ } else if (code === "LABELS_NOT_LIST") {
128
+ repairs.push({
129
+ ...base,
130
+ safe: true,
131
+ action: "set_front_matter_field",
132
+ params: { field: "labels", value: [] },
133
+ optional: true,
134
+ });
135
+ } else if (code === "LABEL_INVALID_ENTRY") {
136
+ repairs.push({ ...base, safe: true, action: "normalize_labels", params: {}, optional: true });
137
+ } else if (code === "ASSIGNMENT_OWNER_INVALID") {
138
+ repairs.push({
139
+ ...base,
140
+ safe: true,
141
+ action: "set_assignment_owner",
142
+ params: { value: null },
143
+ optional: true,
144
+ });
145
+ } else if (code === "VERIFICATION_INVALID") {
146
+ repairs.push({
147
+ ...base,
148
+ safe: true,
149
+ action: "reset_verification_commands",
150
+ params: { commands: [] },
151
+ optional: true,
152
+ });
153
+ } else if (["VERIFICATION_COMMANDS_INVALID", "VERIFICATION_COMMAND_INVALID"].includes(code)) {
154
+ repairs.push({ ...base, safe: true, action: "normalize_verification_commands", params: {}, optional: true });
155
+ }
156
+ }
157
+
158
+ if (autoEnableSafe) {
159
+ for (const repair of repairs) {
160
+ if (repair.safe) {
161
+ repair.enabled = true;
162
+ }
163
+ }
164
+ }
165
+
166
+ return repairs;
167
+ }
168
+
169
+ function loadNodeById(ticketId) {
170
+ const ticketPath = path.join(ticketsDir(), ticketId, "ticket.md");
171
+ if (fs.existsSync(ticketPath)) {
172
+ try {
173
+ const [frontMatter] = loadTicket(ticketPath);
174
+ return {
175
+ id: ticketId,
176
+ title: frontMatter.title ?? ticketId,
177
+ status: frontMatter.status ?? "",
178
+ priority: frontMatter.priority,
179
+ owner: frontMatter.assignment?.owner,
180
+ mode: frontMatter.assignment?.mode,
181
+ path: ticketPath,
182
+ };
183
+ } catch {
184
+ // ignore
185
+ }
186
+ }
187
+
188
+ return {
189
+ id: ticketId,
190
+ title: ticketId,
191
+ status: "",
192
+ path: `/.tickets/${ticketId}/ticket.md`,
193
+ };
194
+ }
195
+
196
+ function loadTicketGraph(ticketRef) {
197
+ const nodes = new Map();
198
+ const edges = [];
199
+ const paths = collectTicketPaths(ticketRef);
200
+ let rootId = null;
201
+
202
+ for (const ticketPath of paths) {
203
+ const [frontMatter] = loadTicket(ticketPath);
204
+ const ticketId = frontMatter.id;
205
+ if (!ticketId) {
206
+ continue;
207
+ }
208
+
209
+ if (ticketRef && !rootId) {
210
+ rootId = ticketId;
211
+ }
212
+
213
+ nodes.set(ticketId, {
214
+ id: ticketId,
215
+ title: frontMatter.title ?? "",
216
+ status: frontMatter.status ?? "",
217
+ priority: frontMatter.priority,
218
+ owner: frontMatter.assignment?.owner,
219
+ mode: frontMatter.assignment?.mode,
220
+ path: ticketPath,
221
+ });
222
+
223
+ for (const dependency of frontMatter.dependencies ?? []) {
224
+ if (!nodes.has(dependency)) {
225
+ nodes.set(dependency, loadNodeById(dependency));
226
+ }
227
+ edges.push({ type: "dependency", from: dependency, to: ticketId });
228
+ }
229
+
230
+ for (const blocked of frontMatter.blocks ?? []) {
231
+ if (!nodes.has(blocked)) {
232
+ nodes.set(blocked, loadNodeById(blocked));
233
+ }
234
+ edges.push({ type: "blocks", from: ticketId, to: blocked });
235
+ }
236
+
237
+ for (const related of frontMatter.related ?? []) {
238
+ if (!nodes.has(related)) {
239
+ nodes.set(related, loadNodeById(related));
240
+ }
241
+ edges.push({ type: "related", from: ticketId, to: related });
242
+ }
243
+ }
244
+
245
+ return {
246
+ nodes: [...nodes.values()],
247
+ edges,
248
+ root_id: rootId,
249
+ };
250
+ }
251
+
252
+ function renderMermaid(graph, includeRelated, timestamp) {
253
+ const statusClasses = {
254
+ todo: "fill:#ddd,stroke:#999",
255
+ doing: "fill:#d0e7ff,stroke:#3b82f6",
256
+ blocked: "fill:#ffe4e6,stroke:#ef4444",
257
+ done: "fill:#dcfce7,stroke:#22c55e",
258
+ canceled: "fill:#f3f4f6,stroke:#111827,color:#374151",
259
+ };
260
+
261
+ const lines = [
262
+ "# Ticket dependency graph",
263
+ `_Generated at ${timestamp} UTC_`,
264
+ "",
265
+ "```mermaid",
266
+ "graph LR",
267
+ ];
268
+
269
+ const nodeIds = new Map();
270
+ graph.nodes.forEach((node, idx) => {
271
+ const nodeRef = `n${idx}`;
272
+ nodeIds.set(node.id, nodeRef);
273
+ const title = (node.title || node.id).replaceAll('"', '\\"');
274
+ const label = `${title}\\n(${node.id})`;
275
+ const status = (node.status || "todo").toLowerCase();
276
+ lines.push(` ${nodeRef}["${label}"]:::status_${status}`);
277
+ lines.push(` click ${nodeRef} "/.tickets/${node.id}/ticket.md" "_blank"`);
278
+ });
279
+
280
+ for (const edge of graph.edges) {
281
+ if (edge.type === "related" && !includeRelated) {
282
+ continue;
283
+ }
284
+ const source = nodeIds.get(edge.from);
285
+ const target = nodeIds.get(edge.to);
286
+ if (!source || !target) {
287
+ continue;
288
+ }
289
+ lines.push(` ${source} --> ${target}`);
290
+ }
291
+
292
+ for (const [status, style] of Object.entries(statusClasses)) {
293
+ lines.push(` classDef status_${status} ${style};`);
294
+ }
295
+
296
+ lines.push("```");
297
+ return lines.join("\n");
298
+ }
299
+
300
+ function renderDot(graph, includeRelated) {
301
+ const colors = {
302
+ todo: "#d1d5db",
303
+ doing: "#60a5fa",
304
+ blocked: "#ef4444",
305
+ done: "#22c55e",
306
+ canceled: "#6b7280",
307
+ };
308
+
309
+ const lines = [
310
+ "digraph G {",
311
+ " rankdir=LR;",
312
+ ' node [shape=box, style=filled, color="#cccccc"];',
313
+ ];
314
+
315
+ const nodeIds = new Map();
316
+ graph.nodes.forEach((node, idx) => {
317
+ const nodeRef = `n${idx}`;
318
+ nodeIds.set(node.id, nodeRef);
319
+ const status = (node.status || "todo").toLowerCase();
320
+ const color = colors[status] ?? colors.todo;
321
+ const label = `${node.title || node.id}\\n(${node.id})\\n${status}`;
322
+ lines.push(
323
+ ` ${nodeRef} [label="${label}", fillcolor="${color}", URL="/.tickets/${node.id}/ticket.md", target="_blank"];`,
324
+ );
325
+ });
326
+
327
+ for (const edge of graph.edges) {
328
+ if (edge.type === "related" && !includeRelated) {
329
+ continue;
330
+ }
331
+ const source = nodeIds.get(edge.from);
332
+ const target = nodeIds.get(edge.to);
333
+ if (!source || !target) {
334
+ continue;
335
+ }
336
+ const style = edge.type === "related" ? "dashed" : "solid";
337
+ lines.push(` ${source} -> ${target} [style=${style}];`);
338
+ }
339
+
340
+ lines.push("}");
341
+ return lines.join("\n");
342
+ }
343
+
344
+ function renderJson(graph, includeRelated) {
345
+ const edges = includeRelated ? graph.edges : graph.edges.filter((edge) => edge.type !== "related");
346
+ return {
347
+ root_id: graph.root_id,
348
+ edges,
349
+ nodes: graph.nodes.map((node) => ({
350
+ id: node.id,
351
+ title: node.title,
352
+ status: node.status,
353
+ priority: node.priority,
354
+ owner: node.owner,
355
+ mode: node.mode,
356
+ href: `/.tickets/${node.id}/ticket.md`,
357
+ })),
358
+ };
359
+ }
360
+
361
+ const AGENTS_LEGACY_SECTION_START = "<!-- @picoai/tickets:agents:start -->";
362
+ const AGENTS_LEGACY_SECTION_END = "<!-- @picoai/tickets:agents:end -->";
363
+ const AGENTS_SECTION_HEADING = "Ticketing Workflow";
364
+ const TICKETS_MANAGED_START = "<!-- @picoai/tickets:managed:start -->";
365
+ const TICKETS_MANAGED_END = "<!-- @picoai/tickets:managed:end -->";
366
+ const TICKETS_LEGACY_START = "<!-- @picoai/tickets:tickets-md:start -->";
367
+ const TICKETS_LEGACY_END = "<!-- @picoai/tickets:tickets-md:end -->";
368
+ const TOOL_VERSION = loadToolVersion();
369
+
370
+ function writeTemplateFile(targetPath, templatePath, apply) {
371
+ if (apply || !fs.existsSync(targetPath)) {
372
+ fs.writeFileSync(targetPath, readTemplate(templatePath));
373
+ }
374
+ }
375
+
376
+ function loadToolVersion() {
377
+ try {
378
+ const sourceDir = path.dirname(fileURLToPath(import.meta.url));
379
+ const packageJsonPath = path.resolve(sourceDir, "..", "package.json");
380
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
381
+ return packageJson.version ?? "unknown";
382
+ } catch {
383
+ return "unknown";
384
+ }
385
+ }
386
+
387
+ function normalizeContent(content) {
388
+ return content.replaceAll("\r\n", "\n");
389
+ }
390
+
391
+ function stripManagedSection(content, startMarker, endMarker) {
392
+ const normalized = normalizeContent(content);
393
+ const startIndex = normalized.indexOf(startMarker);
394
+ const endIndex = normalized.indexOf(endMarker);
395
+ if (startIndex < 0 || endIndex <= startIndex) {
396
+ return normalized;
397
+ }
398
+ const before = normalized.slice(0, startIndex).trimEnd();
399
+ const after = normalized.slice(endIndex + endMarker.length).trimStart();
400
+ if (!before) {
401
+ return after;
402
+ }
403
+ if (!after) {
404
+ return before;
405
+ }
406
+ return `${before}\n\n${after}`;
407
+ }
408
+
409
+ function parseHeadingLine(line) {
410
+ const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
411
+ if (!match) {
412
+ return null;
413
+ }
414
+ return {
415
+ level: match[1].length,
416
+ text: match[2].trim().toLowerCase(),
417
+ };
418
+ }
419
+
420
+ function findHeadingBlockRange(lines, headingText, headingLevel) {
421
+ const target = headingText.trim().toLowerCase();
422
+ let start = -1;
423
+
424
+ for (let index = 0; index < lines.length; index += 1) {
425
+ const parsed = parseHeadingLine(lines[index]);
426
+ if (!parsed) {
427
+ continue;
428
+ }
429
+ if (parsed.level === headingLevel && parsed.text === target) {
430
+ start = index;
431
+ break;
432
+ }
433
+ }
434
+
435
+ if (start < 0) {
436
+ return null;
437
+ }
438
+
439
+ let end = lines.length;
440
+ for (let index = start + 1; index < lines.length; index += 1) {
441
+ const parsed = parseHeadingLine(lines[index]);
442
+ if (!parsed) {
443
+ continue;
444
+ }
445
+ if (parsed.level === headingLevel) {
446
+ end = index;
447
+ break;
448
+ }
449
+ }
450
+
451
+ return { start, end };
452
+ }
453
+
454
+ function extractHeadingBlock(content, headingText, headingLevel) {
455
+ const lines = normalizeContent(content).split("\n");
456
+ const range = findHeadingBlockRange(lines, headingText, headingLevel);
457
+ if (!range) {
458
+ return null;
459
+ }
460
+ return lines.slice(range.start, range.end).join("\n").trimEnd();
461
+ }
462
+
463
+ function joinSections(before, managedSection, after) {
464
+ const sections = [];
465
+ if (before) {
466
+ sections.push(before.trimEnd());
467
+ }
468
+ sections.push(managedSection.trimEnd());
469
+ if (after) {
470
+ sections.push(after.trimStart());
471
+ }
472
+ return `${sections.join("\n\n")}\n`;
473
+ }
474
+
475
+ function replaceHeadingBlock(content, replacement, headingText, headingLevel, fallbackLevels = []) {
476
+ const normalized = normalizeContent(content);
477
+ const lines = normalized.split("\n");
478
+
479
+ let range = findHeadingBlockRange(lines, headingText, headingLevel);
480
+ if (!range) {
481
+ for (const level of fallbackLevels) {
482
+ range = findHeadingBlockRange(lines, headingText, level);
483
+ if (range) {
484
+ break;
485
+ }
486
+ }
487
+ }
488
+
489
+ if (range) {
490
+ const before = lines.slice(0, range.start).join("\n").trimEnd();
491
+ const after = lines.slice(range.end).join("\n").trimStart();
492
+ return joinSections(before, replacement, after);
493
+ }
494
+
495
+ if (!normalized.trim()) {
496
+ return `${replacement.trimEnd()}\n`;
497
+ }
498
+
499
+ return `${normalized.trimEnd()}\n\n${replacement.trimEnd()}\n`;
500
+ }
501
+
502
+ function replaceLegacyAgentsH1Block(content, replacement) {
503
+ const normalized = normalizeContent(content);
504
+ const lines = normalized.split("\n");
505
+ const h1Range = findHeadingBlockRange(lines, AGENTS_SECTION_HEADING, 1);
506
+ if (!h1Range) {
507
+ return null;
508
+ }
509
+
510
+ let end = h1Range.end;
511
+ const bootstrappingRange = findHeadingBlockRange(lines, "Bootstrapping TICKETS.md", 2);
512
+ if (bootstrappingRange && bootstrappingRange.start > h1Range.start) {
513
+ end = lines.length;
514
+ for (let index = bootstrappingRange.end; index < lines.length; index += 1) {
515
+ const parsed = parseHeadingLine(lines[index]);
516
+ if (!parsed) {
517
+ continue;
518
+ }
519
+ if (parsed.level <= 2) {
520
+ end = index;
521
+ break;
522
+ }
523
+ }
524
+ }
525
+
526
+ const before = lines.slice(0, h1Range.start).join("\n").trimEnd();
527
+ const after = lines.slice(end).join("\n").trimStart();
528
+ return joinSections(before, replacement, after);
529
+ }
530
+
531
+ function extractManagedSection(content, startMarker, endMarker) {
532
+ const normalized = normalizeContent(content);
533
+ const startIndex = normalized.indexOf(startMarker);
534
+ const endIndex = normalized.indexOf(endMarker);
535
+ if (startIndex < 0 || endIndex <= startIndex) {
536
+ return null;
537
+ }
538
+ return normalized.slice(startIndex, endIndex + endMarker.length).trimEnd();
539
+ }
540
+
541
+ function injectTicketsManagedMetadata(managedSection) {
542
+ const normalized = normalizeContent(managedSection);
543
+ const lines = normalized.split("\n");
544
+ const headingIndex = lines.findIndex((line) => /^##\s+/.test(line.trim()));
545
+ if (headingIndex < 0) {
546
+ return normalized.trimEnd();
547
+ }
548
+
549
+ const metadata = [
550
+ `- applied_at: ${iso8601(nowUtc())}`,
551
+ `- written_by: @picoai/tickets@${TOOL_VERSION}`,
552
+ `- spec_version: ${FORMAT_VERSION}`,
553
+ `- version_url: ${FORMAT_VERSION_URL}`,
554
+ ];
555
+
556
+ const before = lines.slice(0, headingIndex + 1);
557
+ const after = lines.slice(headingIndex + 1);
558
+ return [...before, "", ...metadata, "", ...after].join("\n").replaceAll(/\n{3,}/g, "\n\n").trimEnd();
559
+ }
560
+
561
+ function upsertTicketsMdManagedSection(existingContent, templateContent) {
562
+ const managedFromTemplate = extractManagedSection(templateContent, TICKETS_MANAGED_START, TICKETS_MANAGED_END);
563
+ if (!managedFromTemplate) {
564
+ throw new Error("Template is missing managed TICKETS.md markers.");
565
+ }
566
+ const managedSection = injectTicketsManagedMetadata(managedFromTemplate);
567
+
568
+ let normalizedExisting = normalizeContent(existingContent);
569
+ normalizedExisting = stripManagedSection(normalizedExisting, TICKETS_LEGACY_START, TICKETS_LEGACY_END);
570
+
571
+ const startIndex = normalizedExisting.indexOf(TICKETS_MANAGED_START);
572
+ const endIndex = normalizedExisting.indexOf(TICKETS_MANAGED_END);
573
+
574
+ if (startIndex >= 0 && endIndex > startIndex) {
575
+ const before = normalizedExisting.slice(0, startIndex).trimEnd();
576
+ const after = normalizedExisting.slice(endIndex + TICKETS_MANAGED_END.length).trimStart();
577
+ return joinSections(before, managedSection, after);
578
+ }
579
+
580
+ if (!normalizedExisting.trim()) {
581
+ return `${managedSection}\n`;
582
+ }
583
+
584
+ return `${normalizedExisting.trimEnd()}\n\n${managedSection}\n`;
585
+ }
586
+
587
+ function syncTicketsMd(root, apply) {
588
+ const ticketsDocPath = path.join(root, "TICKETS.md");
589
+ const templateContent = readTemplate(path.join(".tickets", "spec", "TICKETS.md"));
590
+ const exists = fs.existsSync(ticketsDocPath);
591
+
592
+ if (!exists) {
593
+ fs.writeFileSync(ticketsDocPath, templateContent);
594
+ }
595
+
596
+ if (apply) {
597
+ const existing = fs.readFileSync(ticketsDocPath, "utf8");
598
+ const next = upsertTicketsMdManagedSection(existing, templateContent);
599
+ if (next !== existing) {
600
+ fs.writeFileSync(ticketsDocPath, next);
601
+ }
602
+ }
603
+ }
604
+
605
+ function upsertAgentsSection(existingContent, templateContent) {
606
+ const managedBlock = extractHeadingBlock(templateContent, AGENTS_SECTION_HEADING, 2);
607
+ if (!managedBlock) {
608
+ throw new Error("Template is missing the managed AGENTS.md heading block.");
609
+ }
610
+
611
+ const withoutLegacyMarkers = stripManagedSection(
612
+ normalizeContent(existingContent),
613
+ AGENTS_LEGACY_SECTION_START,
614
+ AGENTS_LEGACY_SECTION_END,
615
+ );
616
+ const lines = normalizeContent(withoutLegacyMarkers).split("\n");
617
+ if (findHeadingBlockRange(lines, AGENTS_SECTION_HEADING, 2)) {
618
+ return replaceHeadingBlock(withoutLegacyMarkers, managedBlock, AGENTS_SECTION_HEADING, 2);
619
+ }
620
+
621
+ const migratedLegacyH1 = replaceLegacyAgentsH1Block(withoutLegacyMarkers, managedBlock);
622
+ if (migratedLegacyH1) {
623
+ return migratedLegacyH1;
624
+ }
625
+
626
+ return replaceHeadingBlock(withoutLegacyMarkers, managedBlock, AGENTS_SECTION_HEADING, 2);
627
+ }
628
+
629
+ function applyAgentsMdSection(root, templateContent) {
630
+ const agentsMdPath = path.join(root, "AGENTS.md");
631
+ const existing = fs.existsSync(agentsMdPath) ? fs.readFileSync(agentsMdPath, "utf8") : "";
632
+ const next = upsertAgentsSection(existing, templateContent);
633
+ if (next !== existing) {
634
+ fs.writeFileSync(agentsMdPath, next);
635
+ }
636
+ }
637
+
638
+ function generateExampleTickets() {
639
+ ensureDir(ticketsDir());
640
+ const now = nowUtc();
641
+ const runStarted = isoBasic(now);
642
+
643
+ const ids = {
644
+ parent: newUuidv7().toLowerCase(),
645
+ backend: newUuidv7().toLowerCase(),
646
+ frontend: newUuidv7().toLowerCase(),
647
+ testing: newUuidv7().toLowerCase(),
648
+ docs: newUuidv7().toLowerCase(),
649
+ release: newUuidv7().toLowerCase(),
650
+ bugfix: newUuidv7().toLowerCase(),
651
+ };
652
+
653
+ const specs = [
654
+ {
655
+ key: "parent",
656
+ title: "Feature Alpha epic (parent ticket)",
657
+ status: "doing",
658
+ priority: "high",
659
+ labels: ["epic", "planning"],
660
+ assignment: { mode: "mixed", owner: "team:core" },
661
+ related: ["backend", "frontend", "testing", "docs", "release"],
662
+ agent_limits: {
663
+ iteration_timebox_minutes: 20,
664
+ max_iterations: 6,
665
+ max_tool_calls: 80,
666
+ checkpoint_every_minutes: 5,
667
+ },
668
+ verification: { commands: ["npm test", "npx @picoai/tickets validate"] },
669
+ body: {
670
+ description: "Track delivery of Feature Alpha and coordinate child tickets.",
671
+ acceptance: [
672
+ "Children tickets created and linked",
673
+ "Rollup status kept current",
674
+ "Release plan agreed",
675
+ ],
676
+ verification: ["npx @picoai/tickets validate"],
677
+ },
678
+ logs: [
679
+ {
680
+ summary: "Epic created and split into child tickets.",
681
+ tickets_created: ["backend", "frontend", "testing", "docs"],
682
+ next_steps: ["Coordinate release window", "Monitor blockers"],
683
+ },
684
+ ],
685
+ },
686
+ {
687
+ key: "backend",
688
+ title: "Feature Alpha API backend",
689
+ status: "doing",
690
+ priority: "high",
691
+ labels: ["backend", "api"],
692
+ assignment: { mode: "agent_only", owner: "agent:codex" },
693
+ dependencies: ["parent"],
694
+ blocks: ["frontend", "testing", "release"],
695
+ agent_limits: {
696
+ iteration_timebox_minutes: 15,
697
+ max_iterations: 4,
698
+ max_tool_calls: 60,
699
+ checkpoint_every_minutes: 5,
700
+ },
701
+ verification: { commands: ["npm test"] },
702
+ body: {
703
+ description: "Implement service endpoints and data model for Feature Alpha.",
704
+ acceptance: ["Endpoints implemented", "Schema migrations applied", "Integration tests pass"],
705
+ verification: ["npm test"],
706
+ },
707
+ logs: [
708
+ {
709
+ summary: "Scaffolded API and outlined endpoints.",
710
+ decisions: ["Using UUID primary keys", "Respond with JSON:API style"],
711
+ created_from: "parent",
712
+ context_carried_over: ["Acceptance criteria from parent", "Release target"],
713
+ },
714
+ ],
715
+ },
716
+ {
717
+ key: "frontend",
718
+ title: "Feature Alpha frontend UI",
719
+ status: "todo",
720
+ priority: "medium",
721
+ labels: ["frontend", "ui"],
722
+ dependencies: ["backend"],
723
+ related: ["testing"],
724
+ verification: { commands: ["npm test", "npm run lint"] },
725
+ body: {
726
+ description: "Build UI flows for Feature Alpha on the web client.",
727
+ acceptance: ["Screens implemented", "API integrated", "Accessibility checks pass"],
728
+ verification: ["npm test", "npm run lint", "npm run test:a11y"],
729
+ },
730
+ logs: [
731
+ {
732
+ summary: "Waiting on API responses to stabilize.",
733
+ blockers: ["Backend contract not finalized"],
734
+ created_from: "parent",
735
+ context_carried_over: ["Design mocks v1.2", "API schema draft"],
736
+ },
737
+ ],
738
+ },
739
+ {
740
+ key: "testing",
741
+ title: "Feature Alpha integration tests",
742
+ status: "todo",
743
+ priority: "medium",
744
+ labels: ["qa"],
745
+ dependencies: ["backend", "frontend"],
746
+ verification: { commands: ["npm test"] },
747
+ body: {
748
+ description: "Add end-to-end coverage for Alpha flows.",
749
+ acceptance: ["E2E happy path", "Error paths covered", "Regression suite green"],
750
+ verification: ["npm test"],
751
+ },
752
+ logs: [
753
+ {
754
+ summary: "Outlined E2E scenarios to automate.",
755
+ next_steps: ["Set up test data fixtures"],
756
+ created_from: "parent",
757
+ context_carried_over: ["Frontend flow chart", "Backend contract v1"],
758
+ },
759
+ ],
760
+ },
761
+ {
762
+ key: "docs",
763
+ title: "Feature Alpha documentation",
764
+ status: "todo",
765
+ priority: "low",
766
+ labels: ["docs"],
767
+ dependencies: ["testing"],
768
+ verification: { commands: ["npm run lint:docs"] },
769
+ body: {
770
+ description: "Document user guide and API reference for Alpha.",
771
+ acceptance: ["User guide drafted", "API examples updated", "Changelog entry added"],
772
+ verification: ["npm run lint:docs"],
773
+ },
774
+ logs: [
775
+ {
776
+ summary: "Preparing outline; waiting on test results.",
777
+ blockers: ["Integration tests pending"],
778
+ created_from: "parent",
779
+ context_carried_over: ["Feature overview", "Known limitations"],
780
+ },
781
+ ],
782
+ },
783
+ {
784
+ key: "release",
785
+ title: "Feature Alpha release coordination",
786
+ status: "todo",
787
+ priority: "high",
788
+ labels: ["release"],
789
+ dependencies: ["testing"],
790
+ blocks: ["bugfix"],
791
+ verification: { commands: ["npx @picoai/tickets validate"] },
792
+ body: {
793
+ description: "Plan release window and rollout steps.",
794
+ acceptance: ["Release checklist approved", "Rollout scheduled", "Comms ready"],
795
+ verification: ["npx @picoai/tickets validate"],
796
+ },
797
+ logs: [
798
+ {
799
+ summary: "Drafted release checklist; waiting on test green.",
800
+ next_steps: ["Book release window"],
801
+ },
802
+ ],
803
+ },
804
+ {
805
+ key: "bugfix",
806
+ title: "Bugfix: address regression found during Alpha",
807
+ status: "blocked",
808
+ priority: "high",
809
+ labels: ["bug", "regression"],
810
+ dependencies: ["backend"],
811
+ related: ["testing"],
812
+ verification: { commands: ["npm test"] },
813
+ body: {
814
+ description: "Fix regression uncovered in integration tests.",
815
+ acceptance: ["Repro scenario fixed", "Regression test added", "No new failures"],
816
+ verification: ["npm test"],
817
+ },
818
+ logs: [
819
+ {
820
+ summary: "Blocked until backend fix lands.",
821
+ blockers: ["Awaiting backend deployment"],
822
+ },
823
+ ],
824
+ },
825
+ ];
826
+
827
+ for (const spec of specs) {
828
+ const ticketId = ids[spec.key];
829
+ const ticketDir = path.join(ticketsDir(), ticketId);
830
+ ensureDir(path.join(ticketDir, "logs"));
831
+
832
+ const frontMatter = {
833
+ id: ticketId,
834
+ version: FORMAT_VERSION,
835
+ version_url: FORMAT_VERSION_URL,
836
+ title: spec.title,
837
+ status: spec.status,
838
+ created_at: iso8601(now),
839
+ };
840
+
841
+ if (spec.priority) {
842
+ frontMatter.priority = spec.priority;
843
+ }
844
+ if (spec.labels) {
845
+ frontMatter.labels = spec.labels;
846
+ }
847
+ if (spec.assignment) {
848
+ frontMatter.assignment = spec.assignment;
849
+ }
850
+ for (const relationshipKey of ["dependencies", "blocks", "related"]) {
851
+ if (spec[relationshipKey]) {
852
+ frontMatter[relationshipKey] = spec[relationshipKey].map((value) => ids[value]);
853
+ }
854
+ }
855
+ if (spec.agent_limits) {
856
+ frontMatter.agent_limits = spec.agent_limits;
857
+ }
858
+ if (spec.verification) {
859
+ frontMatter.verification = spec.verification;
860
+ }
861
+
862
+ const bodyLines = [
863
+ "# Ticket",
864
+ "",
865
+ "## Description",
866
+ spec.body.description,
867
+ "",
868
+ "## Acceptance Criteria",
869
+ ...spec.body.acceptance.map((item) => `- [ ] ${item}`),
870
+ "",
871
+ "## Verification",
872
+ ...spec.body.verification.map((item) => `- ${item}`),
873
+ "",
874
+ ];
875
+
876
+ writeTicket(path.join(ticketDir, "ticket.md"), frontMatter, bodyLines.join("\n"));
877
+
878
+ for (const logSpec of spec.logs ?? []) {
879
+ const runId = newUuidv7();
880
+ const logPath = path.join(ticketDir, "logs", `${runStarted}-${runId}.jsonl`);
881
+ const logEntry = {
882
+ version: FORMAT_VERSION,
883
+ version_url: FORMAT_VERSION_URL,
884
+ ts: iso8601(nowUtc()),
885
+ run_started: runStarted,
886
+ actor_type: "agent",
887
+ actor_id: "tickets-init",
888
+ summary: logSpec.summary,
889
+ written_by: "tickets",
890
+ };
891
+
892
+ for (const key of [
893
+ "decisions",
894
+ "next_steps",
895
+ "blockers",
896
+ "tickets_created",
897
+ "created_from",
898
+ "context_carried_over",
899
+ ]) {
900
+ if (!(key in logSpec)) {
901
+ continue;
902
+ }
903
+
904
+ if (key === "tickets_created") {
905
+ logEntry[key] = logSpec[key].map((value) => ids[value]);
906
+ } else if (key === "created_from") {
907
+ logEntry[key] = ids[logSpec[key]] ?? logSpec[key];
908
+ } else {
909
+ logEntry[key] = logSpec[key];
910
+ }
911
+ }
912
+
913
+ appendJsonl(logPath, logEntry);
914
+ }
915
+ }
916
+ }
917
+
918
+ async function cmdInit(options) {
919
+ ensureDir(ticketsDir());
920
+ const root = repoRoot();
921
+ const repoBaseDir = path.join(root, BASE_DIR);
922
+ ensureDir(repoBaseDir);
923
+ const apply = Boolean(options.apply);
924
+
925
+ syncTicketsMd(root, apply);
926
+
927
+ const agentsPath = path.join(root, "AGENTS_EXAMPLE.md");
928
+ const agentsTemplatePath = path.join(".tickets", "spec", "AGENTS_EXAMPLE.md");
929
+ if (apply) {
930
+ applyAgentsMdSection(root, readTemplate(agentsTemplatePath));
931
+ } else {
932
+ writeTemplateFile(agentsPath, agentsTemplatePath, false);
933
+ }
934
+
935
+ const versionDir = path.join(repoBaseDir, "version");
936
+ ensureDir(versionDir);
937
+
938
+ const specPath = path.join(versionDir, "20260205-tickets-spec.md");
939
+ writeTemplateFile(specPath, path.join(".tickets", "spec", "version", "20260205-tickets-spec.md"), apply);
940
+
941
+ const proposedPath = path.join(versionDir, "PROPOSED-tickets-spec.md");
942
+ writeTemplateFile(proposedPath, path.join(".tickets", "spec", "version", "PROPOSED-tickets-spec.md"), apply);
943
+
944
+ if (options.examples) {
945
+ generateExampleTickets();
946
+ }
947
+
948
+ process.stdout.write("Initialized.\n");
949
+ return 0;
950
+ }
951
+
952
+ async function cmdNew(options) {
953
+ ensureDir(ticketsDir());
954
+ const ticketId = newUuidv7().toLowerCase();
955
+ const ticketDir = path.join(ticketsDir(), ticketId);
956
+ ensureDir(path.join(ticketDir, "logs"));
957
+
958
+ const frontMatter = {
959
+ id: ticketId,
960
+ version: FORMAT_VERSION,
961
+ version_url: FORMAT_VERSION_URL,
962
+ title: options.title,
963
+ status: options.status,
964
+ created_at: options.createdAt || iso8601(nowUtc()),
965
+ };
966
+
967
+ if (options.priority) {
968
+ frontMatter.priority = options.priority;
969
+ }
970
+ if (options.labels?.length) {
971
+ frontMatter.labels = options.labels;
972
+ }
973
+ if (options.assignmentMode || options.assignmentOwner) {
974
+ frontMatter.assignment = {
975
+ mode: options.assignmentMode,
976
+ owner: options.assignmentOwner,
977
+ };
978
+ }
979
+
980
+ for (const [key, value] of [
981
+ ["dependencies", options.dependencies],
982
+ ["blocks", options.blocks],
983
+ ["related", options.related],
984
+ ]) {
985
+ if (value?.length) {
986
+ frontMatter[key] = value;
987
+ }
988
+ }
989
+
990
+ const agentLimits = {};
991
+ if (options.iterationTimeboxMinutes) {
992
+ agentLimits.iteration_timebox_minutes = options.iterationTimeboxMinutes;
993
+ }
994
+ if (options.maxIterations) {
995
+ agentLimits.max_iterations = options.maxIterations;
996
+ }
997
+ if (options.maxToolCalls) {
998
+ agentLimits.max_tool_calls = options.maxToolCalls;
999
+ }
1000
+ if (options.checkpointEveryMinutes) {
1001
+ agentLimits.checkpoint_every_minutes = options.checkpointEveryMinutes;
1002
+ }
1003
+ if (Object.keys(agentLimits).length > 0) {
1004
+ frontMatter.agent_limits = agentLimits;
1005
+ }
1006
+
1007
+ if (options.verificationCommands?.length) {
1008
+ frontMatter.verification = { commands: options.verificationCommands };
1009
+ }
1010
+
1011
+ const body = [
1012
+ "# Ticket",
1013
+ "",
1014
+ "> Before starting: read `TICKETS.md` (canonical workflow) and confirm you understand how to use this ticketing system.",
1015
+ "",
1016
+ "## Description",
1017
+ "(fill in)",
1018
+ "",
1019
+ "## Acceptance Criteria",
1020
+ "- [ ] Define clear, checkable outcomes.",
1021
+ "",
1022
+ "## Verification",
1023
+ "- (add commands or steps)",
1024
+ "",
1025
+ ].join("\n");
1026
+
1027
+ writeTicket(path.join(ticketDir, "ticket.md"), frontMatter, body);
1028
+ process.stdout.write(`${ticketId}\n`);
1029
+ return 0;
1030
+ }
1031
+
1032
+ async function cmdValidate(options) {
1033
+ const ticketPaths = collectTicketPaths(options.ticket);
1034
+ const issues = [];
1035
+
1036
+ for (const ticketPath of ticketPaths) {
1037
+ const [ticketIssues] = validateTicket(ticketPath, options.allFields);
1038
+ issues.push(...ticketIssues);
1039
+
1040
+ const logsDir = path.join(path.dirname(ticketPath), "logs");
1041
+ if (!fs.existsSync(logsDir)) {
1042
+ continue;
1043
+ }
1044
+
1045
+ const logFiles = fs
1046
+ .readdirSync(logsDir, { withFileTypes: true })
1047
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
1048
+ .map((entry) => path.join(logsDir, entry.name))
1049
+ .sort((a, b) => a.localeCompare(b));
1050
+
1051
+ for (const logFile of logFiles) {
1052
+ issues.push(...validateRunLog(logFile, false));
1053
+ }
1054
+ }
1055
+
1056
+ issues.forEach((issue, index) => {
1057
+ if (!issue.id) {
1058
+ issue.id = `I${String(index + 1).padStart(4, "0")}`;
1059
+ }
1060
+ });
1061
+
1062
+ if (options.issues) {
1063
+ const report = {
1064
+ schema_version: 1,
1065
+ generated_at: iso8601(nowUtc()),
1066
+ tool: "tickets",
1067
+ targets: ticketPaths,
1068
+ issues,
1069
+ repairs: buildRepairsFromIssues(issues, { includeOptional: options.allFields }),
1070
+ };
1071
+
1072
+ const content = yaml.stringify(report);
1073
+ if (options.output) {
1074
+ fs.writeFileSync(path.resolve(repoRoot(), options.output), content);
1075
+ } else {
1076
+ process.stdout.write(content);
1077
+ }
1078
+ } else {
1079
+ printIssues(issues);
1080
+ }
1081
+
1082
+ return hasErrors(issues) ? 1 : 0;
1083
+ }
1084
+
1085
+ async function cmdStatus(options) {
1086
+ const ticketPath = resolveTicketPath(options.ticket);
1087
+ const [frontMatter, body] = loadTicket(ticketPath);
1088
+
1089
+ frontMatter.status = options.status;
1090
+ writeTicket(ticketPath, frontMatter, body);
1091
+
1092
+ if (options.log) {
1093
+ const runId = options.runId || newUuidv7();
1094
+ const runStarted = (options.runStarted || isoBasic(nowUtc())).replaceAll(" ", "");
1095
+ const entry = {
1096
+ version: FORMAT_VERSION,
1097
+ version_url: FORMAT_VERSION_URL,
1098
+ ts: iso8601(nowUtc()),
1099
+ run_started: runStarted,
1100
+ actor_type: "human",
1101
+ actor_id: "status-change",
1102
+ summary: `Status set to ${options.status}`,
1103
+ written_by: "tickets",
1104
+ };
1105
+
1106
+ const logPath = path.join(path.dirname(ticketPath), "logs", `${runStarted}-${runId}.jsonl`);
1107
+ appendJsonl(logPath, entry);
1108
+ }
1109
+
1110
+ return 0;
1111
+ }
1112
+
1113
+ async function cmdLog(options) {
1114
+ const ticketPath = resolveTicketPath(options.ticket);
1115
+ const runId = options.runId || newUuidv7();
1116
+ const runStarted = (options.runStarted || isoBasic(nowUtc())).replaceAll(" ", "");
1117
+
1118
+ const entry = {
1119
+ version: FORMAT_VERSION,
1120
+ version_url: FORMAT_VERSION_URL,
1121
+ ts: iso8601(nowUtc()),
1122
+ run_started: runStarted,
1123
+ actor_type: options.actorType,
1124
+ actor_id: options.actorId,
1125
+ summary: options.summary,
1126
+ };
1127
+
1128
+ if (options.machine) {
1129
+ entry.written_by = "tickets";
1130
+ }
1131
+ if (options.changes?.length) {
1132
+ entry.changes = { files: options.changes };
1133
+ }
1134
+ if (options.decisions?.length) {
1135
+ entry.decisions = options.decisions;
1136
+ }
1137
+ if (options.nextSteps?.length) {
1138
+ entry.next_steps = options.nextSteps;
1139
+ }
1140
+ if (options.blockers?.length) {
1141
+ entry.blockers = options.blockers;
1142
+ }
1143
+ if (options.ticketsCreated?.length) {
1144
+ entry.tickets_created = options.ticketsCreated;
1145
+ }
1146
+ if (options.createdFrom) {
1147
+ entry.created_from = options.createdFrom;
1148
+ }
1149
+ if (options.contextCarriedOver?.length) {
1150
+ entry.context_carried_over = options.contextCarriedOver;
1151
+ }
1152
+ if (options.verificationCommands?.length || options.verificationResults) {
1153
+ entry.verification = {
1154
+ commands: options.verificationCommands || [],
1155
+ results: options.verificationResults || "",
1156
+ };
1157
+ }
1158
+
1159
+ const logPath = path.join(path.dirname(ticketPath), "logs", `${runStarted}-${runId}.jsonl`);
1160
+ appendJsonl(logPath, entry);
1161
+ return 0;
1162
+ }
1163
+
1164
+ async function cmdList(options) {
1165
+ const rows = listTickets({
1166
+ status: options.status,
1167
+ priority: options.priority,
1168
+ mode: options.mode,
1169
+ owner: options.owner,
1170
+ label: options.label,
1171
+ text: options.text,
1172
+ });
1173
+
1174
+ if (options.json) {
1175
+ process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
1176
+ return 0;
1177
+ }
1178
+
1179
+ if (rows.length === 0) {
1180
+ process.stdout.write("No tickets.\n");
1181
+ return 0;
1182
+ }
1183
+
1184
+ const headers = ["id", "title", "status", "priority", "owner", "mode", "last_updated"];
1185
+ process.stdout.write(`${headers.join(" | ")}\n`);
1186
+ for (const row of rows) {
1187
+ process.stdout.write(`${headers.map((key) => String(row[key] ?? "")).join(" | ")}\n`);
1188
+ }
1189
+
1190
+ return 0;
1191
+ }
1192
+
1193
+ async function cmdRepair(options) {
1194
+ const nonInteractive = options.nonInteractive;
1195
+
1196
+ if (options.issuesFile) {
1197
+ const data = loadIssuesFile(path.resolve(repoRoot(), options.issuesFile));
1198
+ const repairs = Array.isArray(data.repairs) ? data.repairs : [];
1199
+ const changes = options.interactive
1200
+ ? await runInteractive(repairs, { includeOptional: options.allFields })
1201
+ : await applyRepairs(repairs, {
1202
+ nonInteractive,
1203
+ includeOptional: options.allFields,
1204
+ });
1205
+
1206
+ for (const change of changes) {
1207
+ process.stdout.write(`${change}\n`);
1208
+ }
1209
+
1210
+ return changes.length > 0 ? 0 : 1;
1211
+ }
1212
+
1213
+ let targets;
1214
+ if (options.ticket) {
1215
+ targets = [resolveTicketPath(options.ticket)];
1216
+ } else {
1217
+ targets = collectTicketPaths(null);
1218
+ }
1219
+
1220
+ const repairs = [];
1221
+ for (const ticketPath of targets) {
1222
+ const [issues] = validateTicket(ticketPath, options.allFields);
1223
+ repairs.push(
1224
+ ...buildRepairsFromIssues(issues, {
1225
+ includeOptional: options.allFields,
1226
+ autoEnableSafe: !options.interactive,
1227
+ }),
1228
+ );
1229
+ }
1230
+
1231
+ const changes = options.interactive
1232
+ ? await runInteractive(repairs, { includeOptional: options.allFields })
1233
+ : await applyRepairs(repairs, {
1234
+ nonInteractive,
1235
+ includeOptional: options.allFields,
1236
+ });
1237
+
1238
+ for (const change of changes) {
1239
+ process.stdout.write(`${change}\n`);
1240
+ }
1241
+
1242
+ return changes.length > 0 ? 0 : 1;
1243
+ }
1244
+
1245
+ async function cmdGraph(options) {
1246
+ const graph = loadTicketGraph(options.ticket);
1247
+ if (graph.nodes.length === 0) {
1248
+ process.stdout.write("No tickets found.\n");
1249
+ return 1;
1250
+ }
1251
+
1252
+ const graphDir = path.join(repoRoot(), ".tickets", "graph");
1253
+ ensureDir(graphDir);
1254
+
1255
+ const timestamp = isoBasic(nowUtc());
1256
+ const base = options.ticket
1257
+ ? `dependencies_for_${graph.root_id || "subset"}`
1258
+ : "dependencies";
1259
+ const ext = { mermaid: "md", dot: "dot", json: "json" }[options.format];
1260
+ const outPath = options.output
1261
+ ? path.resolve(repoRoot(), options.output)
1262
+ : path.join(graphDir, `${timestamp}_${base}.${ext}`);
1263
+
1264
+ if (options.format === "json") {
1265
+ const json = renderJson(graph, options.related);
1266
+ fs.writeFileSync(outPath, `${JSON.stringify(json, null, 2)}\n`);
1267
+ } else if (options.format === "dot") {
1268
+ fs.writeFileSync(outPath, renderDot(graph, options.related));
1269
+ } else {
1270
+ fs.writeFileSync(outPath, renderMermaid(graph, options.related, timestamp));
1271
+ }
1272
+
1273
+ process.stdout.write(`${outPath}\n`);
1274
+ return 0;
1275
+ }
1276
+
1277
+ export async function run(argv = process.argv.slice(2)) {
1278
+ const program = new Command();
1279
+ program.name("tickets").description("Repo-native ticketing CLI");
1280
+
1281
+ program
1282
+ .command("init")
1283
+ .description("Initialize tickets structure")
1284
+ .option("--examples", "Generate example tickets and logs")
1285
+ .option(
1286
+ "--apply",
1287
+ "Update managed TICKETS.md + AGENTS.md Ticketing Workflow block; skip AGENTS_EXAMPLE.md output",
1288
+ )
1289
+ .action(async (options) => {
1290
+ process.exitCode = await cmdInit(options);
1291
+ });
1292
+
1293
+ program
1294
+ .command("new")
1295
+ .description("Create new ticket")
1296
+ .requiredOption("--title <title>")
1297
+ .option("--status <status>", "Ticket status", "todo")
1298
+ .option("--priority <priority>")
1299
+ .option("--label <label>", "Label", collectOption, [])
1300
+ .option("--assignment-mode <mode>")
1301
+ .option("--assignment-owner <owner>")
1302
+ .option("--dependency <ticketId>", "Dependency ticket id", collectOption, [])
1303
+ .option("--block <ticketId>", "Blocked ticket id", collectOption, [])
1304
+ .option("--related <ticketId>", "Related ticket id", collectOption, [])
1305
+ .option("--iteration-timebox-minutes <minutes>")
1306
+ .option("--max-iterations <count>")
1307
+ .option("--max-tool-calls <count>")
1308
+ .option("--checkpoint-every-minutes <minutes>")
1309
+ .option("--verification-command <command>", "Verification command", collectOption, [])
1310
+ .option("--created-at <timestamp>")
1311
+ .action(async (options) => {
1312
+ if (!STATUS_VALUES.includes(options.status)) {
1313
+ throw new Error(`Invalid --status. Use one of: ${STATUS_VALUES.join(", ")}`);
1314
+ }
1315
+ if (options.priority && !PRIORITY_VALUES.includes(options.priority)) {
1316
+ throw new Error(`Invalid --priority. Use one of: ${PRIORITY_VALUES.join(", ")}`);
1317
+ }
1318
+ if (options.assignmentMode && !ASSIGNMENT_MODE_VALUES.includes(options.assignmentMode)) {
1319
+ throw new Error(
1320
+ `Invalid --assignment-mode. Use one of: ${ASSIGNMENT_MODE_VALUES.join(", ")}`,
1321
+ );
1322
+ }
1323
+ process.exitCode = await cmdNew({
1324
+ title: options.title,
1325
+ status: options.status,
1326
+ priority: options.priority,
1327
+ labels: options.label,
1328
+ assignmentMode: options.assignmentMode,
1329
+ assignmentOwner: options.assignmentOwner,
1330
+ dependencies: options.dependency,
1331
+ blocks: options.block,
1332
+ related: options.related,
1333
+ iterationTimeboxMinutes: options.iterationTimeboxMinutes
1334
+ ? Number.parseInt(options.iterationTimeboxMinutes, 10)
1335
+ : undefined,
1336
+ maxIterations: options.maxIterations ? Number.parseInt(options.maxIterations, 10) : undefined,
1337
+ maxToolCalls: options.maxToolCalls ? Number.parseInt(options.maxToolCalls, 10) : undefined,
1338
+ checkpointEveryMinutes: options.checkpointEveryMinutes
1339
+ ? Number.parseInt(options.checkpointEveryMinutes, 10)
1340
+ : undefined,
1341
+ verificationCommands: options.verificationCommand,
1342
+ createdAt: options.createdAt,
1343
+ });
1344
+ });
1345
+
1346
+ program
1347
+ .command("validate")
1348
+ .description("Validate tickets")
1349
+ .option("--ticket <ticket>")
1350
+ .option("--issues", "Output machine-readable issues/repairs")
1351
+ .option("--output <file>", "Output path for issues report")
1352
+ .option("--all-fields", "Validate optional front-matter fields too")
1353
+ .action(async (options) => {
1354
+ process.exitCode = await cmdValidate({
1355
+ ticket: options.ticket,
1356
+ issues: Boolean(options.issues),
1357
+ output: options.output,
1358
+ allFields: Boolean(options.allFields),
1359
+ });
1360
+ });
1361
+
1362
+ program
1363
+ .command("status")
1364
+ .description("Update ticket status")
1365
+ .requiredOption("--ticket <ticket>")
1366
+ .requiredOption("--status <status>")
1367
+ .option("--log", "Write a status-change log entry")
1368
+ .option("--run-id <runId>")
1369
+ .option("--run-started <runStarted>")
1370
+ .action(async (options) => {
1371
+ if (!STATUS_VALUES.includes(options.status)) {
1372
+ throw new Error(`Invalid --status. Use one of: ${STATUS_VALUES.join(", ")}`);
1373
+ }
1374
+ process.exitCode = await cmdStatus({
1375
+ ticket: options.ticket,
1376
+ status: options.status,
1377
+ log: Boolean(options.log),
1378
+ runId: options.runId,
1379
+ runStarted: options.runStarted,
1380
+ });
1381
+ });
1382
+
1383
+ program
1384
+ .command("log")
1385
+ .description("Append a run log entry")
1386
+ .requiredOption("--ticket <ticket>")
1387
+ .option("--run-id <runId>")
1388
+ .option("--run-started <runStarted>")
1389
+ .requiredOption("--actor-type <actorType>")
1390
+ .requiredOption("--actor-id <actorId>")
1391
+ .requiredOption("--summary <summary>")
1392
+ .option("--machine")
1393
+ .option("--changes <files...>")
1394
+ .option("--decisions <decisions...>")
1395
+ .option("--next-steps <nextSteps...>")
1396
+ .option("--blockers <blockers...>")
1397
+ .option("--tickets-created <tickets...>")
1398
+ .option("--created-from <ticketId>")
1399
+ .option("--context-carried-over <items...>")
1400
+ .option("--verification-commands <commands...>")
1401
+ .option("--verification-results <results>")
1402
+ .action(async (options) => {
1403
+ if (!["human", "agent"].includes(options.actorType)) {
1404
+ throw new Error("Invalid --actor-type. Use one of: human, agent");
1405
+ }
1406
+ process.exitCode = await cmdLog({
1407
+ ticket: options.ticket,
1408
+ runId: options.runId,
1409
+ runStarted: options.runStarted,
1410
+ actorType: options.actorType,
1411
+ actorId: options.actorId,
1412
+ summary: options.summary,
1413
+ machine: Boolean(options.machine),
1414
+ changes: options.changes,
1415
+ decisions: options.decisions,
1416
+ nextSteps: options.nextSteps,
1417
+ blockers: options.blockers,
1418
+ ticketsCreated: options.ticketsCreated,
1419
+ createdFrom: options.createdFrom,
1420
+ contextCarriedOver: options.contextCarriedOver,
1421
+ verificationCommands: options.verificationCommands,
1422
+ verificationResults: options.verificationResults,
1423
+ });
1424
+ });
1425
+
1426
+ program
1427
+ .command("list")
1428
+ .description("List tickets")
1429
+ .option("--status <status>")
1430
+ .option("--priority <priority>")
1431
+ .option("--mode <mode>")
1432
+ .option("--owner <owner>")
1433
+ .option("--label <label>")
1434
+ .option("--text <text>")
1435
+ .option("--json", "JSON output")
1436
+ .action(async (options) => {
1437
+ process.exitCode = await cmdList(options);
1438
+ });
1439
+
1440
+ program
1441
+ .command("repair")
1442
+ .description("Repair tickets")
1443
+ .option("--ticket <ticket>")
1444
+ .option("--all", "Repair all tickets")
1445
+ .option("--issues-file <file>")
1446
+ .option("--interactive", "Interactive mode")
1447
+ .option("--non-interactive", "Fail if unresolved values are required")
1448
+ .option("--all-fields", "Repair optional front-matter fields too")
1449
+ .action(async (options) => {
1450
+ process.exitCode = await cmdRepair({
1451
+ ticket: options.ticket,
1452
+ all: Boolean(options.all),
1453
+ issuesFile: options.issuesFile,
1454
+ interactive: Boolean(options.interactive),
1455
+ nonInteractive: Boolean(options.nonInteractive),
1456
+ allFields: Boolean(options.allFields),
1457
+ });
1458
+ });
1459
+
1460
+ program
1461
+ .command("graph")
1462
+ .description("Dependency graph")
1463
+ .option("--ticket <ticket>")
1464
+ .option("--format <format>", "mermaid | dot | json", "mermaid")
1465
+ .option("--output <file>")
1466
+ .option("--related", "Include related edges")
1467
+ .option("--no-related", "Exclude related edges")
1468
+ .action(async (options) => {
1469
+ if (!["mermaid", "dot", "json"].includes(options.format)) {
1470
+ throw new Error("Invalid --format. Use one of: mermaid, dot, json");
1471
+ }
1472
+ process.exitCode = await cmdGraph({
1473
+ ticket: options.ticket,
1474
+ format: options.format,
1475
+ output: options.output,
1476
+ related: options.related,
1477
+ });
1478
+ });
1479
+
1480
+ try {
1481
+ await program.parseAsync(argv, { from: "user" });
1482
+ } catch (error) {
1483
+ process.stderr.write(`${String(error.message ?? error)}\n`);
1484
+ process.exitCode = 2;
1485
+ }
1486
+
1487
+ return process.exitCode ?? 0;
1488
+ }