@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.
package/src/cli.js CHANGED
@@ -8,14 +8,24 @@ import yaml from "yaml";
8
8
  import {
9
9
  ASSIGNMENT_MODE_VALUES,
10
10
  BASE_DIR,
11
+ DEFAULT_CLAIM_TTL_MINUTES,
11
12
  FORMAT_VERSION,
12
13
  FORMAT_VERSION_URL,
14
+ GRAPH_VIEW_VALUES,
15
+ LIST_SORT_VALUES,
16
+ PLANNING_NODE_TYPES,
13
17
  PRIORITY_VALUES,
18
+ RESOLUTION_VALUES,
14
19
  STATUS_VALUES,
15
20
  } from "./lib/constants.js";
21
+ import { deriveActiveClaim, loadClaimEvents } from "./lib/claims.js";
22
+ import { loadWorkflowProfile, validateRepoConfig } from "./lib/config.js";
23
+ import { invalidatePlanningIndex, loadPlanningSnapshot, refreshPlanningIndexIfPresent } from "./lib/index.js";
16
24
  import { listTickets } from "./lib/listing.js";
25
+ import { buildGraphData, buildPlanSummary } from "./lib/planning.js";
26
+ import { syncRepoConfig, syncRepoSkill } from "./lib/projections.js";
17
27
  import { applyRepairs, loadIssuesFile, runInteractive } from "./lib/repair.js";
18
- import { collectTicketPaths, validateRunLog, validateTicket } from "./lib/validation.js";
28
+ import { collectTicketPaths, validatePlanningTopology, validateRunLog, validateTicket } from "./lib/validation.js";
19
29
  import {
20
30
  appendJsonl,
21
31
  ensureDir,
@@ -86,9 +96,64 @@ function normalizeContextItems(values) {
86
96
  return values.map((value) => String(value).trim()).filter(Boolean);
87
97
  }
88
98
 
99
+ function groupIdSignature(groupIds = []) {
100
+ return [...groupIds].sort((a, b) => a.localeCompare(b)).join(",");
101
+ }
102
+
103
+ function resolveGroupTargets(groupIds, snapshot) {
104
+ const targets = [];
105
+ for (const groupId of groupIds ?? []) {
106
+ const row = snapshot.nodesById.get(groupId);
107
+ if (!row) {
108
+ throw new Error(`Unknown --group-id target: ${groupId}`);
109
+ }
110
+ if (!["group", "checkpoint"].includes(row.planning.node_type)) {
111
+ throw new Error(`--group-id must reference a group or checkpoint ticket: ${groupId}`);
112
+ }
113
+ targets.push(row);
114
+ }
115
+ return targets;
116
+ }
117
+
118
+ function inferInheritedScalar(groupTargets, key, optionName) {
119
+ const values = [...new Set(groupTargets.map((row) => row.planning[key]).filter((value) => value))];
120
+ if (values.length > 1) {
121
+ throw new Error(`Cannot infer --${optionName}; referenced groups disagree.`);
122
+ }
123
+ return values[0] ?? null;
124
+ }
125
+
126
+ function inferNextRank(snapshot, planning) {
127
+ if (!planning.lane) {
128
+ return null;
129
+ }
130
+
131
+ const signature = groupIdSignature(planning.group_ids);
132
+ let maxRank = 0;
133
+ for (const row of snapshot.rows) {
134
+ if (row.planning.node_type !== planning.node_type) {
135
+ continue;
136
+ }
137
+ if (groupIdSignature(row.planning.group_ids) !== signature) {
138
+ continue;
139
+ }
140
+ if (row.planning.lane !== planning.lane) {
141
+ continue;
142
+ }
143
+ if ((row.planning.horizon ?? null) !== (planning.horizon ?? null)) {
144
+ continue;
145
+ }
146
+ if (Number.isInteger(row.planning.rank) && row.planning.rank > maxRank) {
147
+ maxRank = row.planning.rank;
148
+ }
149
+ }
150
+
151
+ return maxRank + 1;
152
+ }
153
+
89
154
  function printIssues(issues) {
90
155
  for (const issue of issues) {
91
- const location = issue.ticket_path ?? issue.log ?? "";
156
+ const location = issue.ticket_path ?? issue.log ?? issue.config_path ?? "";
92
157
  process.stdout.write(`${String(issue.severity ?? "?").toUpperCase()}: ${issue.message} (${location})\n`);
93
158
  }
94
159
  }
@@ -236,90 +301,7 @@ function buildRepairsFromIssues(issues, options = {}) {
236
301
  return repairs;
237
302
  }
238
303
 
239
- function loadNodeById(ticketId) {
240
- const ticketPath = path.join(ticketsDir(), ticketId, "ticket.md");
241
- if (fs.existsSync(ticketPath)) {
242
- try {
243
- const [frontMatter] = loadTicket(ticketPath);
244
- return {
245
- id: ticketId,
246
- title: frontMatter.title ?? ticketId,
247
- status: frontMatter.status ?? "",
248
- priority: frontMatter.priority,
249
- owner: frontMatter.assignment?.owner,
250
- mode: frontMatter.assignment?.mode,
251
- path: ticketPath,
252
- };
253
- } catch {
254
- // ignore
255
- }
256
- }
257
-
258
- return {
259
- id: ticketId,
260
- title: ticketId,
261
- status: "",
262
- path: `/.tickets/${ticketId}/ticket.md`,
263
- };
264
- }
265
-
266
- function loadTicketGraph(ticketRef) {
267
- const nodes = new Map();
268
- const edges = [];
269
- const paths = collectTicketPaths(ticketRef);
270
- let rootId = null;
271
-
272
- for (const ticketPath of paths) {
273
- const [frontMatter] = loadTicket(ticketPath);
274
- const ticketId = frontMatter.id;
275
- if (!ticketId) {
276
- continue;
277
- }
278
-
279
- if (ticketRef && !rootId) {
280
- rootId = ticketId;
281
- }
282
-
283
- nodes.set(ticketId, {
284
- id: ticketId,
285
- title: frontMatter.title ?? "",
286
- status: frontMatter.status ?? "",
287
- priority: frontMatter.priority,
288
- owner: frontMatter.assignment?.owner,
289
- mode: frontMatter.assignment?.mode,
290
- path: ticketPath,
291
- });
292
-
293
- for (const dependency of frontMatter.dependencies ?? []) {
294
- if (!nodes.has(dependency)) {
295
- nodes.set(dependency, loadNodeById(dependency));
296
- }
297
- edges.push({ type: "dependency", from: dependency, to: ticketId });
298
- }
299
-
300
- for (const blocked of frontMatter.blocks ?? []) {
301
- if (!nodes.has(blocked)) {
302
- nodes.set(blocked, loadNodeById(blocked));
303
- }
304
- edges.push({ type: "blocks", from: ticketId, to: blocked });
305
- }
306
-
307
- for (const related of frontMatter.related ?? []) {
308
- if (!nodes.has(related)) {
309
- nodes.set(related, loadNodeById(related));
310
- }
311
- edges.push({ type: "related", from: ticketId, to: related });
312
- }
313
- }
314
-
315
- return {
316
- nodes: [...nodes.values()],
317
- edges,
318
- root_id: rootId,
319
- };
320
- }
321
-
322
- function renderMermaid(graph, includeRelated, timestamp) {
304
+ function renderMermaid(graph, includeRelated, timestamp, view) {
323
305
  const statusClasses = {
324
306
  todo: "fill:#ddd,stroke:#999",
325
307
  doing: "fill:#d0e7ff,stroke:#3b82f6",
@@ -329,7 +311,7 @@ function renderMermaid(graph, includeRelated, timestamp) {
329
311
  };
330
312
 
331
313
  const lines = [
332
- "# Ticket dependency graph",
314
+ `# Ticket ${view} graph`,
333
315
  `_Generated at ${timestamp} UTC_`,
334
316
  "",
335
317
  "```mermaid",
@@ -341,7 +323,17 @@ function renderMermaid(graph, includeRelated, timestamp) {
341
323
  const nodeRef = `n${idx}`;
342
324
  nodeIds.set(node.id, nodeRef);
343
325
  const title = (node.title || node.id).replaceAll('"', '\\"');
344
- const label = `${title}\\n(${node.id})`;
326
+ const metadata = [
327
+ `status=${node.status || "todo"}`,
328
+ `type=${node.planning.node_type ?? ""}`,
329
+ `lane=${node.planning.lane ?? "-"}`,
330
+ `rank=${node.planning.rank ?? "-"}`,
331
+ `horizon=${node.planning.horizon ?? "-"}`,
332
+ ];
333
+ if (node.resolution) {
334
+ metadata.push(`resolution=${node.resolution}`);
335
+ }
336
+ const label = `${title}\\n(${node.id})\\n${metadata.join("\\n")}`;
345
337
  const status = (node.status || "todo").toLowerCase();
346
338
  lines.push(` ${nodeRef}["${label}"]:::status_${status}`);
347
339
  lines.push(` click ${nodeRef} "/.tickets/${node.id}/ticket.md" "_blank"`);
@@ -356,7 +348,8 @@ function renderMermaid(graph, includeRelated, timestamp) {
356
348
  if (!source || !target) {
357
349
  continue;
358
350
  }
359
- lines.push(` ${source} --> ${target}`);
351
+ const connector = edge.type === "contains" ? "-.->" : "-->";
352
+ lines.push(` ${source} ${connector}|${edge.type}| ${target}`);
360
353
  }
361
354
 
362
355
  for (const [status, style] of Object.entries(statusClasses)) {
@@ -388,7 +381,17 @@ function renderDot(graph, includeRelated) {
388
381
  nodeIds.set(node.id, nodeRef);
389
382
  const status = (node.status || "todo").toLowerCase();
390
383
  const color = colors[status] ?? colors.todo;
391
- const label = `${node.title || node.id}\\n(${node.id})\\n${status}`;
384
+ const metadata = [
385
+ `status=${status}`,
386
+ `type=${node.planning.node_type ?? ""}`,
387
+ `lane=${node.planning.lane ?? "-"}`,
388
+ `rank=${node.planning.rank ?? "-"}`,
389
+ `horizon=${node.planning.horizon ?? "-"}`,
390
+ ];
391
+ if (node.resolution) {
392
+ metadata.push(`resolution=${node.resolution}`);
393
+ }
394
+ const label = `${node.title || node.id}\\n(${node.id})\\n${metadata.join("\\n")}`;
392
395
  lines.push(
393
396
  ` ${nodeRef} [label="${label}", fillcolor="${color}", URL="/.tickets/${node.id}/ticket.md", target="_blank"];`,
394
397
  );
@@ -403,8 +406,8 @@ function renderDot(graph, includeRelated) {
403
406
  if (!source || !target) {
404
407
  continue;
405
408
  }
406
- const style = edge.type === "related" ? "dashed" : "solid";
407
- lines.push(` ${source} -> ${target} [style=${style}];`);
409
+ const style = ["related", "contains"].includes(edge.type) ? "dashed" : "solid";
410
+ lines.push(` ${source} -> ${target} [style=${style}, label="${edge.type}"];`);
408
411
  }
409
412
 
410
413
  lines.push("}");
@@ -423,6 +426,14 @@ function renderJson(graph, includeRelated) {
423
426
  priority: node.priority,
424
427
  owner: node.owner,
425
428
  mode: node.mode,
429
+ node_type: node.planning.node_type,
430
+ group_ids: node.planning.group_ids,
431
+ lane: node.planning.lane,
432
+ rank: node.planning.rank,
433
+ horizon: node.planning.horizon,
434
+ precedes: node.planning.precedes,
435
+ resolution: node.resolution,
436
+ ready: node.ready,
426
437
  href: `/.tickets/${node.id}/ticket.md`,
427
438
  })),
428
439
  };
@@ -727,6 +738,7 @@ function generateExampleTickets() {
727
738
  status: "doing",
728
739
  priority: "high",
729
740
  labels: ["epic", "planning"],
741
+ planning: { node_type: "group", lane: "build", rank: 1, horizon: "current" },
730
742
  assignment: { mode: "mixed", owner: "team:core" },
731
743
  related: ["backend", "frontend", "testing", "docs", "release"],
732
744
  agent_limits: {
@@ -760,6 +772,7 @@ function generateExampleTickets() {
760
772
  status: "doing",
761
773
  priority: "high",
762
774
  labels: ["backend", "api"],
775
+ planning: { node_type: "work", group_ids: ["parent"], lane: "build", rank: 1, horizon: "current", precedes: ["frontend", "testing"] },
763
776
  assignment: { mode: "agent_only", owner: "agent:codex" },
764
777
  dependencies: ["parent"],
765
778
  blocks: ["frontend", "testing", "release"],
@@ -782,6 +795,11 @@ function generateExampleTickets() {
782
795
  created_from: "parent",
783
796
  context: ["Acceptance criteria from parent", "Release target"],
784
797
  },
798
+ {
799
+ summary: "Agent claimed backend work.",
800
+ event_type: "claim",
801
+ claim: { action: "acquire", holder_id: "agent:codex", holder_type: "agent", ttl_minutes: 60 },
802
+ },
785
803
  ],
786
804
  },
787
805
  {
@@ -790,6 +808,7 @@ function generateExampleTickets() {
790
808
  status: "todo",
791
809
  priority: "medium",
792
810
  labels: ["frontend", "ui"],
811
+ planning: { node_type: "work", group_ids: ["parent"], lane: "build", rank: 2, horizon: "current" },
793
812
  dependencies: ["backend"],
794
813
  related: ["testing"],
795
814
  verification: { commands: ["npm test", "npm run lint"] },
@@ -813,6 +832,7 @@ function generateExampleTickets() {
813
832
  status: "todo",
814
833
  priority: "medium",
815
834
  labels: ["qa"],
835
+ planning: { node_type: "work", group_ids: ["parent"], lane: "verify", rank: 1, horizon: "current" },
816
836
  dependencies: ["backend", "frontend"],
817
837
  verification: { commands: ["npm test"] },
818
838
  body: {
@@ -835,6 +855,7 @@ function generateExampleTickets() {
835
855
  status: "todo",
836
856
  priority: "low",
837
857
  labels: ["docs"],
858
+ planning: { node_type: "work", group_ids: ["parent"], lane: "launch", rank: 2, horizon: "next" },
838
859
  dependencies: ["testing"],
839
860
  verification: { commands: ["npm run lint:docs"] },
840
861
  body: {
@@ -857,6 +878,7 @@ function generateExampleTickets() {
857
878
  status: "todo",
858
879
  priority: "high",
859
880
  labels: ["release"],
881
+ planning: { node_type: "checkpoint", group_ids: ["parent"], lane: "launch", rank: 1, horizon: "current" },
860
882
  dependencies: ["testing"],
861
883
  blocks: ["bugfix"],
862
884
  verification: { commands: ["npx @picoai/tickets validate"] },
@@ -876,9 +898,11 @@ function generateExampleTickets() {
876
898
  {
877
899
  key: "bugfix",
878
900
  title: "Bugfix: address regression found during Alpha",
879
- status: "blocked",
901
+ status: "canceled",
880
902
  priority: "high",
881
903
  labels: ["bug", "regression"],
904
+ planning: { node_type: "work", group_ids: ["parent"], lane: "build", rank: 3, horizon: "current" },
905
+ resolution: "dropped",
882
906
  dependencies: ["backend"],
883
907
  related: ["testing"],
884
908
  verification: { commands: ["npm test"] },
@@ -889,9 +913,9 @@ function generateExampleTickets() {
889
913
  },
890
914
  logs: [
891
915
  {
892
- summary: "Blocked until backend fix lands.",
916
+ summary: "Dropped after mitigation in backend workstream.",
893
917
  context: ["Regression repro identified", "Awaiting backend deployment before retry"],
894
- blockers: ["Awaiting backend deployment"],
918
+ decisions: ["Folded remediation into backend ticket"],
895
919
  },
896
920
  ],
897
921
  },
@@ -920,6 +944,16 @@ function generateExampleTickets() {
920
944
  if (spec.assignment) {
921
945
  frontMatter.assignment = spec.assignment;
922
946
  }
947
+ if (spec.planning) {
948
+ frontMatter.planning = {
949
+ ...spec.planning,
950
+ group_ids: spec.planning.group_ids?.map((value) => ids[value]) ?? spec.planning.group_ids,
951
+ precedes: spec.planning.precedes?.map((value) => ids[value]) ?? spec.planning.precedes,
952
+ };
953
+ }
954
+ if (spec.resolution) {
955
+ frontMatter.resolution = spec.resolution;
956
+ }
923
957
  for (const relationshipKey of ["dependencies", "blocks", "related"]) {
924
958
  if (spec[relationshipKey]) {
925
959
  frontMatter[relationshipKey] = spec[relationshipKey].map((value) => ids[value]);
@@ -959,12 +993,13 @@ function generateExampleTickets() {
959
993
  actor_type: "agent",
960
994
  actor_id: "tickets-init",
961
995
  summary: logSpec.summary,
962
- event_type: "work",
996
+ event_type: logSpec.event_type ?? "work",
963
997
  written_by: "tickets",
964
998
  };
965
999
 
966
1000
  for (const key of [
967
1001
  "context",
1002
+ "claim",
968
1003
  "decisions",
969
1004
  "next_steps",
970
1005
  "blockers",
@@ -979,6 +1014,12 @@ function generateExampleTickets() {
979
1014
  logEntry[key] = logSpec[key].map((value) => ids[value]);
980
1015
  } else if (key === "created_from") {
981
1016
  logEntry[key] = ids[logSpec[key]] ?? logSpec[key];
1017
+ } else if (key === "claim") {
1018
+ logEntry.claim = {
1019
+ ...logSpec.claim,
1020
+ claim_id: newUuidv7().toLowerCase(),
1021
+ expires_at: iso8601(new Date(now.getTime() + (logSpec.claim.ttl_minutes ?? 60) * 60 * 1000)),
1022
+ };
982
1023
  } else {
983
1024
  logEntry[key] = logSpec[key];
984
1025
  }
@@ -997,6 +1038,8 @@ async function cmdInit(options) {
997
1038
  const apply = Boolean(options.apply);
998
1039
 
999
1040
  syncTicketsMd(root, apply);
1041
+ syncRepoConfig(root);
1042
+ syncRepoSkill(root, apply);
1000
1043
 
1001
1044
  const agentsPath = path.join(root, "AGENTS_EXAMPLE.md");
1002
1045
  const agentsTemplatePath = path.join(".tickets", "spec", "AGENTS_EXAMPLE.md");
@@ -1009,8 +1052,15 @@ async function cmdInit(options) {
1009
1052
  const versionDir = path.join(repoBaseDir, "version");
1010
1053
  ensureDir(versionDir);
1011
1054
 
1012
- const currentSpecPath = path.join(versionDir, "20260311-tickets-spec.md");
1013
- writeTemplateFile(currentSpecPath, path.join(".tickets", "spec", "version", "20260311-tickets-spec.md"), apply);
1055
+ const currentSpecPath = path.join(versionDir, "20260317-2-tickets-spec.md");
1056
+ writeTemplateFile(currentSpecPath, path.join(".tickets", "spec", "version", "20260317-2-tickets-spec.md"), apply);
1057
+
1058
+ const previousCurrentSpecPath = path.join(versionDir, "20260311-tickets-spec.md");
1059
+ writeTemplateFile(
1060
+ previousCurrentSpecPath,
1061
+ path.join(".tickets", "spec", "version", "20260311-tickets-spec.md"),
1062
+ apply,
1063
+ );
1014
1064
 
1015
1065
  const previousSpecPath = path.join(versionDir, "20260205-tickets-spec.md");
1016
1066
  writeTemplateFile(previousSpecPath, path.join(".tickets", "spec", "version", "20260205-tickets-spec.md"), apply);
@@ -1022,15 +1072,48 @@ async function cmdInit(options) {
1022
1072
  generateExampleTickets();
1023
1073
  }
1024
1074
 
1075
+ invalidatePlanningIndex();
1025
1076
  process.stdout.write("Initialized.\n");
1026
1077
  return 0;
1027
1078
  }
1028
1079
 
1029
1080
  async function cmdNew(options) {
1030
1081
  ensureDir(ticketsDir());
1082
+ const profile = loadWorkflowProfile();
1083
+ const snapshot = loadPlanningSnapshot({ persist: false });
1031
1084
  const ticketId = newUuidv7().toLowerCase();
1032
1085
  const ticketDir = path.join(ticketsDir(), ticketId);
1033
1086
  ensureDir(path.join(ticketDir, "logs"));
1087
+ const groupIds = options.groupIds?.length ? options.groupIds : [];
1088
+ const groupTargets = resolveGroupTargets(groupIds, snapshot);
1089
+
1090
+ let lane = options.lane ?? null;
1091
+ if (lane === null && groupTargets.length > 0) {
1092
+ lane = inferInheritedScalar(groupTargets, "lane", "lane");
1093
+ }
1094
+ if (lane === null) {
1095
+ lane = profile.defaults?.planning?.lane ?? null;
1096
+ }
1097
+
1098
+ let horizon = options.horizon ?? null;
1099
+ if (horizon === null && groupTargets.length > 0) {
1100
+ horizon = inferInheritedScalar(groupTargets, "horizon", "horizon");
1101
+ }
1102
+ if (horizon === null) {
1103
+ horizon = profile.defaults?.planning?.horizon ?? null;
1104
+ }
1105
+
1106
+ const planning = {
1107
+ node_type: options.nodeType || profile.defaults?.planning?.node_type || "work",
1108
+ group_ids: groupIds,
1109
+ lane,
1110
+ rank: options.rank ?? null,
1111
+ horizon,
1112
+ precedes: options.precedes?.length ? options.precedes : [],
1113
+ };
1114
+ if (planning.rank === null && planning.lane) {
1115
+ planning.rank = inferNextRank(snapshot, planning);
1116
+ }
1034
1117
 
1035
1118
  const frontMatter = {
1036
1119
  id: ticketId,
@@ -1084,6 +1167,10 @@ async function cmdNew(options) {
1084
1167
  if (options.verificationCommands?.length) {
1085
1168
  frontMatter.verification = { commands: options.verificationCommands };
1086
1169
  }
1170
+ frontMatter.planning = planning;
1171
+ if (options.resolution) {
1172
+ frontMatter.resolution = options.resolution;
1173
+ }
1087
1174
 
1088
1175
  const body = [
1089
1176
  "# Ticket",
@@ -1102,14 +1189,18 @@ async function cmdNew(options) {
1102
1189
  ].join("\n");
1103
1190
 
1104
1191
  writeTicket(path.join(ticketDir, "ticket.md"), frontMatter, body);
1192
+ refreshPlanningIndexIfPresent();
1105
1193
  process.stdout.write(`${ticketId}\n`);
1106
1194
  return 0;
1107
1195
  }
1108
1196
 
1109
1197
  async function cmdValidate(options) {
1110
1198
  const ticketPaths = collectTicketPaths(options.ticket);
1199
+ const allTicketPaths = collectTicketPaths(null);
1111
1200
  const issues = [];
1112
1201
 
1202
+ issues.push(...validateRepoConfig(repoRoot()));
1203
+
1113
1204
  for (const ticketPath of ticketPaths) {
1114
1205
  const [ticketIssues] = validateTicket(ticketPath, options.allFields);
1115
1206
  issues.push(...ticketIssues);
@@ -1130,6 +1221,8 @@ async function cmdValidate(options) {
1130
1221
  }
1131
1222
  }
1132
1223
 
1224
+ issues.push(...validatePlanningTopology(ticketPaths, allTicketPaths));
1225
+
1133
1226
  issues.forEach((issue, index) => {
1134
1227
  if (!issue.id) {
1135
1228
  issue.id = `I${String(index + 1).padStart(4, "0")}`;
@@ -1192,6 +1285,7 @@ async function cmdStatus(options) {
1192
1285
 
1193
1286
  const logPath = path.join(path.dirname(ticketPath), "logs", `${runStarted}-${runId}.jsonl`);
1194
1287
  appendJsonl(logPath, entry);
1288
+ refreshPlanningIndexIfPresent();
1195
1289
 
1196
1290
  return 0;
1197
1291
  }
@@ -1251,18 +1345,41 @@ async function cmdLog(options) {
1251
1345
 
1252
1346
  const logPath = path.join(path.dirname(ticketPath), "logs", `${runStarted}-${runId}.jsonl`);
1253
1347
  appendJsonl(logPath, entry);
1348
+ refreshPlanningIndexIfPresent();
1254
1349
  return 0;
1255
1350
  }
1256
1351
 
1352
+ function renderTable(headers, rows) {
1353
+ process.stdout.write(`${headers.join(" | ")}\n`);
1354
+ for (const row of rows) {
1355
+ process.stdout.write(`${headers.map((key) => String(row[key] ?? "")).join(" | ")}\n`);
1356
+ }
1357
+ }
1358
+
1257
1359
  async function cmdList(options) {
1258
- const rows = listTickets({
1259
- status: options.status,
1260
- priority: options.priority,
1261
- mode: options.mode,
1262
- owner: options.owner,
1263
- label: options.label,
1264
- text: options.text,
1265
- });
1360
+ const snapshot = loadPlanningSnapshot();
1361
+ const rows = listTickets(
1362
+ snapshot,
1363
+ {
1364
+ status: options.status,
1365
+ priority: options.priority,
1366
+ mode: options.mode,
1367
+ owner: options.owner,
1368
+ label: options.label,
1369
+ text: options.text,
1370
+ nodeType: options.nodeType,
1371
+ group: options.group,
1372
+ lane: options.lane,
1373
+ horizon: options.horizon,
1374
+ claimed: Boolean(options.claimed),
1375
+ claimedBy: options.claimedBy,
1376
+ ready: Boolean(options.ready),
1377
+ },
1378
+ {
1379
+ sortBy: options.sort,
1380
+ reverse: Boolean(options.reverse),
1381
+ },
1382
+ );
1266
1383
 
1267
1384
  if (options.json) {
1268
1385
  process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
@@ -1274,12 +1391,159 @@ async function cmdList(options) {
1274
1391
  return 0;
1275
1392
  }
1276
1393
 
1277
- const headers = ["id", "title", "status", "priority", "owner", "mode", "last_updated"];
1278
- process.stdout.write(`${headers.join(" | ")}\n`);
1279
- for (const row of rows) {
1280
- process.stdout.write(`${headers.map((key) => String(row[key] ?? "")).join(" | ")}\n`);
1394
+ const headers = [
1395
+ "id",
1396
+ "title",
1397
+ "status",
1398
+ "priority",
1399
+ "node_type",
1400
+ "lane",
1401
+ "rank",
1402
+ "horizon",
1403
+ "ready",
1404
+ "claim",
1405
+ "owner",
1406
+ "last_updated",
1407
+ ];
1408
+ renderTable(headers, rows.map((row) => ({ ...row, claim: row.claim_summary })));
1409
+
1410
+ return 0;
1411
+ }
1412
+
1413
+ async function cmdPlan(options) {
1414
+ const snapshot = loadPlanningSnapshot();
1415
+ const summary = buildPlanSummary(snapshot, {
1416
+ group: options.group ?? options.root ?? null,
1417
+ horizon: options.horizon ?? null,
1418
+ });
1419
+
1420
+ if (options.format === "json") {
1421
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
1422
+ return 0;
1423
+ }
1424
+
1425
+ const itemHeaders = [
1426
+ "id",
1427
+ "title",
1428
+ "status",
1429
+ "priority",
1430
+ "node_type",
1431
+ "lane",
1432
+ "rank",
1433
+ "horizon",
1434
+ "claim",
1435
+ "owner",
1436
+ ];
1437
+ const groupHeaders = ["id", "title", "node_type", "lane", "rank", "horizon", "rollup_summary"];
1438
+
1439
+ const activeRows = summary.active.map((row) => ({ ...row }));
1440
+ const blockedRows = summary.blocked.map((row) => ({
1441
+ ...row,
1442
+ blocked_by: JSON.stringify(row.blocked_by),
1443
+ }));
1444
+ const groupRows = summary.groups.map((group) => {
1445
+ const rollup = group.rollup ?? {};
1446
+ return {
1447
+ ...group,
1448
+ rollup_summary: `${rollup.done_completed ?? 0}/${rollup.active_leaf ?? 0} complete | merged=${rollup.merged ?? 0} | dropped=${rollup.dropped ?? 0}`,
1449
+ };
1450
+ });
1451
+
1452
+ process.stdout.write("Ready\n");
1453
+ renderTable(itemHeaders, summary.ready.map((row) => ({ ...row, claim: row.claim_summary })));
1454
+ process.stdout.write("\nIn Progress\n");
1455
+ renderTable(itemHeaders, activeRows.map((row) => ({ ...row, claim: row.claim_summary })));
1456
+ process.stdout.write("\nBlocked\n");
1457
+ renderTable([...itemHeaders, "blocked_by"], blockedRows.map((row) => ({ ...row, claim: row.claim_summary })));
1458
+ process.stdout.write("\nGroups / Checkpoints\n");
1459
+ renderTable(groupHeaders, groupRows);
1460
+
1461
+ return 0;
1462
+ }
1463
+
1464
+ async function cmdClaim(options) {
1465
+ const ticketPath = resolveTicketPath(options.ticket);
1466
+ const logsDir = path.join(path.dirname(ticketPath), "logs");
1467
+ ensureDir(logsDir);
1468
+ const profile = loadWorkflowProfile();
1469
+
1470
+ const actorId = resolveActorId(options.actorId);
1471
+ const actorType = resolveActorType(options.actorType, actorId);
1472
+ const events = loadClaimEvents(logsDir);
1473
+ const activeClaim = deriveActiveClaim(events, nowUtc());
1474
+ const runId = options.runId || newUuidv7();
1475
+ const runStarted = (options.runStarted || isoBasic(nowUtc())).replaceAll(" ", "");
1476
+ const ttlMinutes = options.ttlMinutes || profile.defaults?.claims?.ttl_minutes || DEFAULT_CLAIM_TTL_MINUTES;
1477
+ const now = nowUtc();
1478
+
1479
+ let action;
1480
+ let summary;
1481
+ let claimId = newUuidv7().toLowerCase();
1482
+ let supersedesClaimId = null;
1483
+
1484
+ if (options.release) {
1485
+ if (!activeClaim) {
1486
+ process.stdout.write("No active claim.\n");
1487
+ return 1;
1488
+ }
1489
+ if (activeClaim.holder_id !== actorId && !options.force) {
1490
+ process.stdout.write(`Ticket is claimed by ${activeClaim.holder_id} until ${activeClaim.expires_at}.\n`);
1491
+ return 1;
1492
+ }
1493
+ if (activeClaim.holder_id !== actorId && options.force && !options.reason) {
1494
+ throw new Error("Forced claim release requires --reason");
1495
+ }
1496
+ action = "release";
1497
+ claimId = activeClaim.claim_id;
1498
+ summary = `Released claim ${claimId}`;
1499
+ } else if (activeClaim && activeClaim.holder_id === actorId) {
1500
+ action = "renew";
1501
+ claimId = activeClaim.claim_id;
1502
+ summary = `Renewed claim ${claimId}`;
1503
+ } else if (activeClaim && activeClaim.holder_id !== actorId) {
1504
+ if (!options.force) {
1505
+ process.stdout.write(`Ticket is claimed by ${activeClaim.holder_id} until ${activeClaim.expires_at}.\n`);
1506
+ return 1;
1507
+ }
1508
+ if (!options.reason) {
1509
+ throw new Error("Forced claim override requires --reason");
1510
+ }
1511
+ action = "override";
1512
+ supersedesClaimId = activeClaim.claim_id;
1513
+ summary = `Overrode claim ${activeClaim.claim_id}`;
1514
+ } else {
1515
+ action = "acquire";
1516
+ summary = `Acquired claim ${claimId}`;
1517
+ }
1518
+
1519
+ const entry = {
1520
+ version: FORMAT_VERSION,
1521
+ version_url: FORMAT_VERSION_URL,
1522
+ ts: iso8601(now),
1523
+ run_started: runStarted,
1524
+ actor_type: actorType,
1525
+ actor_id: actorId,
1526
+ summary,
1527
+ event_type: "claim",
1528
+ written_by: "tickets",
1529
+ claim: {
1530
+ action,
1531
+ claim_id: claimId,
1532
+ holder_id: actorId,
1533
+ holder_type: actorType,
1534
+ reason: options.reason ?? "",
1535
+ supersedes_claim_id: supersedesClaimId,
1536
+ },
1537
+ };
1538
+
1539
+ if (action !== "release") {
1540
+ entry.claim.ttl_minutes = ttlMinutes;
1541
+ entry.claim.expires_at = iso8601(new Date(now.getTime() + ttlMinutes * 60 * 1000));
1281
1542
  }
1282
1543
 
1544
+ appendJsonl(path.join(logsDir, `${runStarted}-${runId}.jsonl`), entry);
1545
+ refreshPlanningIndexIfPresent();
1546
+ process.stdout.write(`${summary}\n`);
1283
1547
  return 0;
1284
1548
  }
1285
1549
 
@@ -1354,7 +1618,12 @@ async function cmdRepair(options) {
1354
1618
  }
1355
1619
 
1356
1620
  async function cmdGraph(options) {
1357
- const graph = loadTicketGraph(options.ticket);
1621
+ const snapshot = loadPlanningSnapshot();
1622
+ const graph = buildGraphData(snapshot, {
1623
+ ticket: options.ticket,
1624
+ view: options.view,
1625
+ includeRelated: options.related,
1626
+ });
1358
1627
  if (graph.nodes.length === 0) {
1359
1628
  process.stdout.write("No tickets found.\n");
1360
1629
  return 1;
@@ -1365,8 +1634,8 @@ async function cmdGraph(options) {
1365
1634
 
1366
1635
  const timestamp = isoBasic(nowUtc());
1367
1636
  const base = options.ticket
1368
- ? `dependencies_for_${graph.root_id || "subset"}`
1369
- : "dependencies";
1637
+ ? `${options.view}_for_${graph.root_id || "subset"}`
1638
+ : options.view;
1370
1639
  const ext = { mermaid: "md", dot: "dot", json: "json" }[options.format];
1371
1640
  const outPath = options.output
1372
1641
  ? path.resolve(repoRoot(), options.output)
@@ -1378,7 +1647,7 @@ async function cmdGraph(options) {
1378
1647
  } else if (options.format === "dot") {
1379
1648
  fs.writeFileSync(outPath, renderDot(graph, options.related));
1380
1649
  } else {
1381
- fs.writeFileSync(outPath, renderMermaid(graph, options.related, timestamp));
1650
+ fs.writeFileSync(outPath, renderMermaid(graph, options.related, timestamp, options.view));
1382
1651
  }
1383
1652
 
1384
1653
  process.stdout.write(`${outPath}\n`);
@@ -1419,6 +1688,13 @@ export async function run(argv = process.argv.slice(2)) {
1419
1688
  .option("--checkpoint-every-minutes <minutes>")
1420
1689
  .option("--verification-command <command>", "Verification command", collectOption, [])
1421
1690
  .option("--created-at <timestamp>")
1691
+ .option("--node-type <nodeType>")
1692
+ .option("--group-id <groupId>", "Group membership ticket id", collectOption, [])
1693
+ .option("--lane <lane>")
1694
+ .option("--rank <rank>")
1695
+ .option("--horizon <horizon>")
1696
+ .option("--precedes <ticketId>", "Sequence successor ticket id", collectOption, [])
1697
+ .option("--resolution <resolution>")
1422
1698
  .action(async (options) => {
1423
1699
  if (!STATUS_VALUES.includes(options.status)) {
1424
1700
  throw new Error(`Invalid --status. Use one of: ${STATUS_VALUES.join(", ")}`);
@@ -1431,6 +1707,21 @@ export async function run(argv = process.argv.slice(2)) {
1431
1707
  `Invalid --assignment-mode. Use one of: ${ASSIGNMENT_MODE_VALUES.join(", ")}`,
1432
1708
  );
1433
1709
  }
1710
+ if (options.nodeType && !PLANNING_NODE_TYPES.includes(options.nodeType)) {
1711
+ throw new Error(`Invalid --node-type. Use one of: ${PLANNING_NODE_TYPES.join(", ")}`);
1712
+ }
1713
+ if (options.resolution && !RESOLUTION_VALUES.includes(options.resolution)) {
1714
+ throw new Error(`Invalid --resolution. Use one of: ${RESOLUTION_VALUES.join(", ")}`);
1715
+ }
1716
+ if (options.rank) {
1717
+ const rank = Number.parseInt(options.rank, 10);
1718
+ if (!Number.isInteger(rank) || rank <= 0) {
1719
+ throw new Error("Invalid --rank. Use a positive integer");
1720
+ }
1721
+ }
1722
+ if (options.resolution && !["done", "canceled"].includes(options.status)) {
1723
+ throw new Error("Resolution requires terminal status done or canceled");
1724
+ }
1434
1725
  process.exitCode = await cmdNew({
1435
1726
  title: options.title,
1436
1727
  status: options.status,
@@ -1451,6 +1742,13 @@ export async function run(argv = process.argv.slice(2)) {
1451
1742
  : undefined,
1452
1743
  verificationCommands: options.verificationCommand,
1453
1744
  createdAt: options.createdAt,
1745
+ nodeType: options.nodeType,
1746
+ groupIds: options.groupId,
1747
+ lane: options.lane,
1748
+ rank: options.rank ? Number.parseInt(options.rank, 10) : null,
1749
+ horizon: options.horizon,
1750
+ precedes: options.precedes,
1751
+ resolution: options.resolution,
1454
1752
  });
1455
1753
  });
1456
1754
 
@@ -1550,11 +1848,37 @@ export async function run(argv = process.argv.slice(2)) {
1550
1848
  .option("--owner <owner>")
1551
1849
  .option("--label <label>")
1552
1850
  .option("--text <text>")
1851
+ .option("--node-type <nodeType>")
1852
+ .option("--group <ticketId>")
1853
+ .option("--lane <lane>")
1854
+ .option("--horizon <horizon>")
1855
+ .option("--claimed", "Only show claimed tickets")
1856
+ .option("--claimed-by <actorId>")
1857
+ .option("--ready", "Only show ready tickets")
1858
+ .option("--sort <sort>", "ready | priority | lane | rank | updated | title")
1859
+ .option("--reverse", "Reverse the sort order")
1553
1860
  .option("--json", "JSON output")
1554
1861
  .action(async (options) => {
1862
+ if (options.sort && !LIST_SORT_VALUES.includes(options.sort)) {
1863
+ throw new Error(`Invalid --sort. Use one of: ${LIST_SORT_VALUES.join(", ")}`);
1864
+ }
1555
1865
  process.exitCode = await cmdList(options);
1556
1866
  });
1557
1867
 
1868
+ program
1869
+ .command("plan")
1870
+ .description("Summarize portfolio rollups and ready work")
1871
+ .option("--root <ticket>")
1872
+ .option("--group <ticket>")
1873
+ .option("--horizon <horizon>")
1874
+ .option("--format <format>", "table | json", "table")
1875
+ .action(async (options) => {
1876
+ if (!["table", "json"].includes(options.format)) {
1877
+ throw new Error("Invalid --format. Use one of: table, json");
1878
+ }
1879
+ process.exitCode = await cmdPlan(options);
1880
+ });
1881
+
1558
1882
  program
1559
1883
  .command("repair")
1560
1884
  .description("Repair tickets")
@@ -1577,9 +1901,10 @@ export async function run(argv = process.argv.slice(2)) {
1577
1901
 
1578
1902
  program
1579
1903
  .command("graph")
1580
- .description("Dependency graph")
1904
+ .description("Ticket graph")
1581
1905
  .option("--ticket <ticket>")
1582
1906
  .option("--format <format>", "mermaid | dot | json", "mermaid")
1907
+ .option("--view <view>", "dependency | sequence | portfolio | all", "dependency")
1583
1908
  .option("--output <file>")
1584
1909
  .option("--related", "Include related edges")
1585
1910
  .option("--no-related", "Exclude related edges")
@@ -1587,14 +1912,53 @@ export async function run(argv = process.argv.slice(2)) {
1587
1912
  if (!["mermaid", "dot", "json"].includes(options.format)) {
1588
1913
  throw new Error("Invalid --format. Use one of: mermaid, dot, json");
1589
1914
  }
1915
+ if (!GRAPH_VIEW_VALUES.includes(options.view)) {
1916
+ throw new Error(`Invalid --view. Use one of: ${GRAPH_VIEW_VALUES.join(", ")}`);
1917
+ }
1590
1918
  process.exitCode = await cmdGraph({
1591
1919
  ticket: options.ticket,
1592
1920
  format: options.format,
1921
+ view: options.view,
1593
1922
  output: options.output,
1594
1923
  related: options.related,
1595
1924
  });
1596
1925
  });
1597
1926
 
1927
+ program
1928
+ .command("claim")
1929
+ .description("Acquire, renew, release, or override an advisory ticket claim")
1930
+ .requiredOption("--ticket <ticket>")
1931
+ .option("--actor-type <actorType>")
1932
+ .option("--actor-id <actorId>")
1933
+ .option("--run-id <runId>")
1934
+ .option("--run-started <runStarted>")
1935
+ .option("--ttl-minutes <minutes>")
1936
+ .option("--release", "Release the active claim")
1937
+ .option("--force", "Override an active claim held by another actor")
1938
+ .option("--reason <reason>")
1939
+ .action(async (options) => {
1940
+ if (options.actorType && !isValidActorType(options.actorType)) {
1941
+ throw new Error("Invalid --actor-type. Use one of: human, agent");
1942
+ }
1943
+ if (options.ttlMinutes) {
1944
+ const ttl = Number.parseInt(options.ttlMinutes, 10);
1945
+ if (!Number.isInteger(ttl) || ttl <= 0) {
1946
+ throw new Error("Invalid --ttl-minutes. Use a positive integer");
1947
+ }
1948
+ }
1949
+ process.exitCode = await cmdClaim({
1950
+ ticket: options.ticket,
1951
+ actorType: options.actorType,
1952
+ actorId: options.actorId,
1953
+ runId: options.runId,
1954
+ runStarted: options.runStarted,
1955
+ ttlMinutes: options.ttlMinutes ? Number.parseInt(options.ttlMinutes, 10) : undefined,
1956
+ release: Boolean(options.release),
1957
+ force: Boolean(options.force),
1958
+ reason: options.reason,
1959
+ });
1960
+ });
1961
+
1598
1962
  try {
1599
1963
  await program.parseAsync(argv, { from: "user" });
1600
1964
  } catch (error) {