@picoai/tickets 0.2.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.
@@ -3,7 +3,10 @@ import path from "node:path";
3
3
 
4
4
  import {
5
5
  ASSIGNMENT_MODE_VALUES,
6
+ CLAIM_ACTION_VALUES,
7
+ PLANNING_NODE_TYPES,
6
8
  PRIORITY_VALUES,
9
+ RESOLUTION_VALUES,
7
10
  STATUS_VALUES,
8
11
  } from "./constants.js";
9
12
  import {
@@ -211,6 +214,90 @@ export function validateTicket(ticketPath, allFields = false) {
211
214
  }
212
215
  }
213
216
 
217
+ if ("planning" in frontMatter) {
218
+ const planning = frontMatter.planning;
219
+ if (!planning || typeof planning !== "object" || Array.isArray(planning)) {
220
+ issues.push({
221
+ severity: "error",
222
+ code: "PLANNING_INVALID",
223
+ message: "planning must be mapping",
224
+ ticket_path: ticketPath,
225
+ });
226
+ } else {
227
+ if ("node_type" in planning && !PLANNING_NODE_TYPES.includes(planning.node_type)) {
228
+ issues.push({
229
+ severity: "error",
230
+ code: "PLANNING_NODE_TYPE_INVALID",
231
+ message: `planning.node_type must be one of ${PLANNING_NODE_TYPES.join("|")}`,
232
+ ticket_path: ticketPath,
233
+ });
234
+ }
235
+
236
+ for (const key of ["group_ids", "precedes"]) {
237
+ if (!(key in planning)) {
238
+ continue;
239
+ }
240
+ if (!Array.isArray(planning[key])) {
241
+ issues.push({
242
+ severity: "error",
243
+ code: "PLANNING_RELATIONSHIP_INVALID",
244
+ message: `planning.${key} must be list`,
245
+ ticket_path: ticketPath,
246
+ });
247
+ continue;
248
+ }
249
+ for (const relation of planning[key]) {
250
+ if (typeof relation !== "string" || !isUuidv7(relation)) {
251
+ issues.push({
252
+ severity: "error",
253
+ code: "PLANNING_RELATIONSHIP_ID_INVALID",
254
+ message: `planning.${key} entries must be UUIDv7`,
255
+ ticket_path: ticketPath,
256
+ });
257
+ }
258
+ }
259
+ }
260
+
261
+ for (const key of ["lane", "horizon"]) {
262
+ if (key in planning && planning[key] !== null && typeof planning[key] !== "string") {
263
+ issues.push({
264
+ severity: "error",
265
+ code: "PLANNING_SCALAR_INVALID",
266
+ message: `planning.${key} must be string or null`,
267
+ ticket_path: ticketPath,
268
+ });
269
+ }
270
+ }
271
+
272
+ if ("rank" in planning && planning.rank !== null && (!Number.isInteger(planning.rank) || planning.rank <= 0)) {
273
+ issues.push({
274
+ severity: "error",
275
+ code: "PLANNING_RANK_INVALID",
276
+ message: "planning.rank must be a positive integer or null",
277
+ ticket_path: ticketPath,
278
+ });
279
+ }
280
+ }
281
+ }
282
+
283
+ if ("resolution" in frontMatter) {
284
+ if (frontMatter.resolution !== null && !RESOLUTION_VALUES.includes(frontMatter.resolution)) {
285
+ issues.push({
286
+ severity: "error",
287
+ code: "RESOLUTION_INVALID",
288
+ message: `resolution must be one of ${RESOLUTION_VALUES.join("|")} or null`,
289
+ ticket_path: ticketPath,
290
+ });
291
+ } else if (frontMatter.resolution !== null && !["done", "canceled"].includes(frontMatter.status)) {
292
+ issues.push({
293
+ severity: "error",
294
+ code: "RESOLUTION_STATUS_INVALID",
295
+ message: "resolution requires terminal status done|canceled",
296
+ ticket_path: ticketPath,
297
+ });
298
+ }
299
+ }
300
+
214
301
  if ("agent_limits" in frontMatter) {
215
302
  if (!frontMatter.agent_limits || typeof frontMatter.agent_limits !== "object" || Array.isArray(frontMatter.agent_limits)) {
216
303
  issues.push({
@@ -345,6 +432,224 @@ export function validateTicket(ticketPath, allFields = false) {
345
432
  return [issues, frontMatter, body];
346
433
  }
347
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
+
348
653
  export function validateRunLog(logPath, machineStrictDefault) {
349
654
  const issues = [];
350
655
  const filename = path.basename(logPath);
@@ -380,11 +685,11 @@ export function validateRunLog(logPath, machineStrictDefault) {
380
685
  message: "event_type missing",
381
686
  log: loc,
382
687
  });
383
- } else if (!["status", "work"].includes(entry.event_type)) {
688
+ } else if (!["status", "work", "claim"].includes(entry.event_type)) {
384
689
  issues.push({
385
690
  severity: machineEntry ? "error" : "warning",
386
691
  code: "LOG_EVENT_TYPE_INVALID",
387
- message: "event_type must be status|work",
692
+ message: "event_type must be status|work|claim",
388
693
  log: loc,
389
694
  });
390
695
  } else {
@@ -524,6 +829,79 @@ export function validateRunLog(logPath, machineStrictDefault) {
524
829
  });
525
830
  }
526
831
 
832
+ if (eventType === "claim") {
833
+ const claim = entry.claim;
834
+ if (!claim || typeof claim !== "object" || Array.isArray(claim)) {
835
+ issues.push({
836
+ severity: machineEntry ? "error" : "warning",
837
+ code: "CLAIM_INVALID",
838
+ message: "claim event must include claim mapping",
839
+ log: loc,
840
+ });
841
+ } else {
842
+ if (!CLAIM_ACTION_VALUES.includes(claim.action)) {
843
+ issues.push({
844
+ severity: machineEntry ? "error" : "warning",
845
+ code: "CLAIM_ACTION_INVALID",
846
+ message: `claim.action must be one of ${CLAIM_ACTION_VALUES.join("|")}`,
847
+ log: loc,
848
+ });
849
+ }
850
+ if (typeof claim.claim_id !== "string" || !isUuidv7(claim.claim_id)) {
851
+ issues.push({
852
+ severity: machineEntry ? "error" : "warning",
853
+ code: "CLAIM_ID_INVALID",
854
+ message: "claim.claim_id must be UUIDv7",
855
+ log: loc,
856
+ });
857
+ }
858
+ if (typeof claim.holder_id !== "string" || !claim.holder_id.trim()) {
859
+ issues.push({
860
+ severity: machineEntry ? "error" : "warning",
861
+ code: "CLAIM_HOLDER_ID_INVALID",
862
+ message: "claim.holder_id must be non-empty string",
863
+ log: loc,
864
+ });
865
+ }
866
+ if (!["human", "agent"].includes(claim.holder_type)) {
867
+ issues.push({
868
+ severity: machineEntry ? "error" : "warning",
869
+ code: "CLAIM_HOLDER_TYPE_INVALID",
870
+ message: "claim.holder_type must be human|agent",
871
+ log: loc,
872
+ });
873
+ }
874
+ if (claim.action !== "release") {
875
+ if (!Number.isInteger(claim.ttl_minutes) || claim.ttl_minutes <= 0) {
876
+ issues.push({
877
+ severity: machineEntry ? "error" : "warning",
878
+ code: "CLAIM_TTL_INVALID",
879
+ message: "claim.ttl_minutes must be a positive integer",
880
+ log: loc,
881
+ });
882
+ }
883
+ if (!parseIso(claim.expires_at)) {
884
+ issues.push({
885
+ severity: machineEntry ? "error" : "warning",
886
+ code: "CLAIM_EXPIRES_AT_INVALID",
887
+ message: "claim.expires_at must be ISO8601",
888
+ log: loc,
889
+ });
890
+ }
891
+ }
892
+ if ("supersedes_claim_id" in claim && claim.supersedes_claim_id !== null) {
893
+ if (typeof claim.supersedes_claim_id !== "string" || !isUuidv7(claim.supersedes_claim_id)) {
894
+ issues.push({
895
+ severity: machineEntry ? "error" : "warning",
896
+ code: "CLAIM_SUPERSEDES_INVALID",
897
+ message: "claim.supersedes_claim_id must be UUIDv7 or null",
898
+ log: loc,
899
+ });
900
+ }
901
+ }
902
+ }
903
+ }
904
+
527
905
  if ("custom" in entry && (typeof entry.custom !== "object" || !entry.custom || Array.isArray(entry.custom))) {
528
906
  issues.push({
529
907
  severity: machineEntry ? "error" : "warning",