@picoai/tickets 0.3.0 → 0.4.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 +2 -0
- package/.tickets/spec/TICKETS.md +20 -11
- package/.tickets/spec/profile/defaults.yml +2 -0
- package/.tickets/spec/version/20260317-2-tickets-spec.md +106 -0
- package/README.md +23 -2
- package/package.json +1 -1
- package/release-history.json +7 -0
- package/src/cli.js +205 -48
- package/src/lib/config.js +14 -0
- package/src/lib/constants.js +4 -1
- package/src/lib/index.js +241 -0
- package/src/lib/listing.js +6 -3
- package/src/lib/planning.js +249 -152
- package/src/lib/projections.js +9 -0
- package/src/lib/validation.js +218 -0
package/src/lib/validation.js
CHANGED
|
@@ -432,6 +432,224 @@ export function validateTicket(ticketPath, allFields = false) {
|
|
|
432
432
|
return [issues, frontMatter, body];
|
|
433
433
|
}
|
|
434
434
|
|
|
435
|
+
function loadPlanningRows(ticketPaths) {
|
|
436
|
+
const rows = [];
|
|
437
|
+
|
|
438
|
+
for (const ticketPath of ticketPaths) {
|
|
439
|
+
try {
|
|
440
|
+
const [frontMatter] = loadTicket(ticketPath);
|
|
441
|
+
rows.push({
|
|
442
|
+
ticket_path: ticketPath,
|
|
443
|
+
id: frontMatter.id ?? "",
|
|
444
|
+
status: frontMatter.status ?? "",
|
|
445
|
+
dependencies: Array.isArray(frontMatter.dependencies) ? frontMatter.dependencies : [],
|
|
446
|
+
blocks: Array.isArray(frontMatter.blocks) ? frontMatter.blocks : [],
|
|
447
|
+
related: Array.isArray(frontMatter.related) ? frontMatter.related : [],
|
|
448
|
+
planning: {
|
|
449
|
+
node_type: frontMatter.planning?.node_type ?? "work",
|
|
450
|
+
group_ids: Array.isArray(frontMatter.planning?.group_ids) ? frontMatter.planning.group_ids : [],
|
|
451
|
+
lane: frontMatter.planning?.lane ?? null,
|
|
452
|
+
rank: frontMatter.planning?.rank ?? null,
|
|
453
|
+
horizon: frontMatter.planning?.horizon ?? null,
|
|
454
|
+
precedes: Array.isArray(frontMatter.planning?.precedes) ? frontMatter.planning.precedes : [],
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
} catch {
|
|
458
|
+
// Ticket parsing errors are already surfaced by validateTicket.
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return rows;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function relationshipIssue(ticketPath, code, message) {
|
|
466
|
+
return {
|
|
467
|
+
severity: "error",
|
|
468
|
+
code,
|
|
469
|
+
message,
|
|
470
|
+
ticket_path: ticketPath,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function collectCycleNodes(adjacency) {
|
|
475
|
+
const state = new Map();
|
|
476
|
+
const cycleNodes = new Set();
|
|
477
|
+
const stack = [];
|
|
478
|
+
|
|
479
|
+
function visit(nodeId) {
|
|
480
|
+
const status = state.get(nodeId) ?? 0;
|
|
481
|
+
if (status === 1) {
|
|
482
|
+
const startIndex = stack.indexOf(nodeId);
|
|
483
|
+
for (const entry of stack.slice(startIndex)) {
|
|
484
|
+
cycleNodes.add(entry);
|
|
485
|
+
}
|
|
486
|
+
cycleNodes.add(nodeId);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (status === 2) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
state.set(nodeId, 1);
|
|
494
|
+
stack.push(nodeId);
|
|
495
|
+
for (const nextId of adjacency.get(nodeId) ?? []) {
|
|
496
|
+
visit(nextId);
|
|
497
|
+
}
|
|
498
|
+
stack.pop();
|
|
499
|
+
state.set(nodeId, 2);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
for (const nodeId of adjacency.keys()) {
|
|
503
|
+
visit(nodeId);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return cycleNodes;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function validatePlanningTopology(targetTicketPaths, allTicketPaths = targetTicketPaths) {
|
|
510
|
+
const issues = [];
|
|
511
|
+
const allRows = loadPlanningRows(allTicketPaths);
|
|
512
|
+
const rowsById = new Map(allRows.map((row) => [row.id, row]));
|
|
513
|
+
const targetPaths = new Set(targetTicketPaths);
|
|
514
|
+
const targetRows = allRows.filter((row) => targetPaths.has(row.ticket_path));
|
|
515
|
+
|
|
516
|
+
for (const row of targetRows) {
|
|
517
|
+
for (const [key, values] of [
|
|
518
|
+
["dependencies", row.dependencies],
|
|
519
|
+
["blocks", row.blocks],
|
|
520
|
+
["related", row.related],
|
|
521
|
+
]) {
|
|
522
|
+
for (const relationId of values) {
|
|
523
|
+
if (typeof relationId === "string" && isUuidv7(relationId) && !rowsById.has(relationId)) {
|
|
524
|
+
issues.push(
|
|
525
|
+
relationshipIssue(row.ticket_path, "RELATIONSHIP_TARGET_MISSING", `${key} references missing ticket ${relationId}`),
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
for (const relationId of row.planning.group_ids) {
|
|
532
|
+
if (typeof relationId === "string" && isUuidv7(relationId) && !rowsById.has(relationId)) {
|
|
533
|
+
issues.push(
|
|
534
|
+
relationshipIssue(
|
|
535
|
+
row.ticket_path,
|
|
536
|
+
"PLANNING_TARGET_MISSING",
|
|
537
|
+
`planning.group_ids references missing ticket ${relationId}`,
|
|
538
|
+
),
|
|
539
|
+
);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
const target = rowsById.get(relationId);
|
|
543
|
+
if (target && !["group", "checkpoint"].includes(target.planning.node_type)) {
|
|
544
|
+
issues.push(
|
|
545
|
+
relationshipIssue(
|
|
546
|
+
row.ticket_path,
|
|
547
|
+
"PLANNING_GROUP_TARGET_INVALID",
|
|
548
|
+
`planning.group_ids must reference a group or checkpoint ticket: ${relationId}`,
|
|
549
|
+
),
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
for (const relationId of row.planning.precedes) {
|
|
555
|
+
if (typeof relationId === "string" && isUuidv7(relationId) && !rowsById.has(relationId)) {
|
|
556
|
+
issues.push(
|
|
557
|
+
relationshipIssue(
|
|
558
|
+
row.ticket_path,
|
|
559
|
+
"PLANNING_TARGET_MISSING",
|
|
560
|
+
`planning.precedes references missing ticket ${relationId}`,
|
|
561
|
+
),
|
|
562
|
+
);
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
if (relationId === row.id) {
|
|
566
|
+
issues.push(
|
|
567
|
+
relationshipIssue(
|
|
568
|
+
row.ticket_path,
|
|
569
|
+
"PLANNING_PRECEDES_SELF_REFERENCE",
|
|
570
|
+
"planning.precedes must not contain the ticket's own id",
|
|
571
|
+
),
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const precedesAdjacency = new Map();
|
|
578
|
+
for (const row of allRows) {
|
|
579
|
+
precedesAdjacency.set(row.id, [...row.planning.precedes]);
|
|
580
|
+
}
|
|
581
|
+
const precedesCycles = collectCycleNodes(precedesAdjacency);
|
|
582
|
+
for (const row of targetRows) {
|
|
583
|
+
if (precedesCycles.has(row.id)) {
|
|
584
|
+
issues.push(
|
|
585
|
+
relationshipIssue(row.ticket_path, "PLANNING_PRECEDES_CYCLE", "planning.precedes contains a cycle"),
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const groupAdjacency = new Map();
|
|
591
|
+
for (const row of allRows) {
|
|
592
|
+
if (!["group", "checkpoint"].includes(row.planning.node_type)) {
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const edges = row.planning.group_ids.filter((groupId) => {
|
|
596
|
+
const target = rowsById.get(groupId);
|
|
597
|
+
return target && ["group", "checkpoint"].includes(target.planning.node_type);
|
|
598
|
+
});
|
|
599
|
+
groupAdjacency.set(row.id, edges);
|
|
600
|
+
}
|
|
601
|
+
const groupCycles = collectCycleNodes(groupAdjacency);
|
|
602
|
+
for (const row of targetRows) {
|
|
603
|
+
if (groupCycles.has(row.id)) {
|
|
604
|
+
issues.push(
|
|
605
|
+
relationshipIssue(
|
|
606
|
+
row.ticket_path,
|
|
607
|
+
"PLANNING_GROUP_CYCLE",
|
|
608
|
+
"group/checkpoint membership contains a cycle",
|
|
609
|
+
),
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const ranksByScope = new Map();
|
|
615
|
+
for (const row of allRows) {
|
|
616
|
+
if (!Number.isInteger(row.planning.rank)) {
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
const scopeKey = JSON.stringify({
|
|
620
|
+
node_type: row.planning.node_type,
|
|
621
|
+
group_ids: [...row.planning.group_ids].sort((a, b) => a.localeCompare(b)),
|
|
622
|
+
lane: row.planning.lane ?? null,
|
|
623
|
+
horizon: row.planning.horizon ?? null,
|
|
624
|
+
rank: row.planning.rank,
|
|
625
|
+
});
|
|
626
|
+
if (!ranksByScope.has(scopeKey)) {
|
|
627
|
+
ranksByScope.set(scopeKey, []);
|
|
628
|
+
}
|
|
629
|
+
ranksByScope.get(scopeKey).push(row);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
for (const rows of ranksByScope.values()) {
|
|
633
|
+
if (rows.length < 2) {
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
for (const row of rows) {
|
|
637
|
+
if (!targetPaths.has(row.ticket_path)) {
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
issues.push(
|
|
641
|
+
relationshipIssue(
|
|
642
|
+
row.ticket_path,
|
|
643
|
+
"PLANNING_RANK_CONFLICT",
|
|
644
|
+
"planning.rank conflicts with another ticket in the same peer set",
|
|
645
|
+
),
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return issues;
|
|
651
|
+
}
|
|
652
|
+
|
|
435
653
|
export function validateRunLog(logPath, machineStrictDefault) {
|
|
436
654
|
const issues = [];
|
|
437
655
|
const filename = path.basename(logPath);
|