@picoai/tickets 0.1.0 → 0.3.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,12 +8,20 @@ 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
+ PLANNING_NODE_TYPES,
13
16
  PRIORITY_VALUES,
17
+ RESOLUTION_VALUES,
14
18
  STATUS_VALUES,
15
19
  } from "./lib/constants.js";
20
+ import { deriveActiveClaim, loadClaimEvents } from "./lib/claims.js";
21
+ import { loadWorkflowProfile, validateRepoConfig } from "./lib/config.js";
16
22
  import { listTickets } from "./lib/listing.js";
23
+ import { buildGraphData, buildPlanSummary, buildPlanningSnapshot } from "./lib/planning.js";
24
+ import { syncRepoConfig, syncRepoSkill } from "./lib/projections.js";
17
25
  import { applyRepairs, loadIssuesFile, runInteractive } from "./lib/repair.js";
18
26
  import { collectTicketPaths, validateRunLog, validateTicket } from "./lib/validation.js";
19
27
  import {
@@ -40,9 +48,55 @@ function hasErrors(issues) {
40
48
  return issues.some((issue) => issue.severity === "error");
41
49
  }
42
50
 
51
+ function isValidActorType(value) {
52
+ return ["human", "agent"].includes(value);
53
+ }
54
+
55
+ function resolveActorId(explicitActorId) {
56
+ const candidate = explicitActorId ?? process.env.TICKETS_ACTOR_ID;
57
+ if (typeof candidate === "string" && candidate.trim()) {
58
+ return candidate.trim();
59
+ }
60
+
61
+ const localUser = process.env.USER ?? process.env.USERNAME;
62
+ if (typeof localUser === "string" && localUser.trim()) {
63
+ return `@${localUser.trim()}`;
64
+ }
65
+
66
+ return "unknown";
67
+ }
68
+
69
+ function resolveActorType(explicitActorType, actorId) {
70
+ const candidate = explicitActorType ?? process.env.TICKETS_ACTOR_TYPE;
71
+ if (typeof candidate === "string" && candidate.trim()) {
72
+ if (!isValidActorType(candidate.trim())) {
73
+ throw new Error("Invalid actor type. Use one of: human, agent");
74
+ }
75
+ return candidate.trim();
76
+ }
77
+
78
+ if (typeof actorId === "string") {
79
+ if (actorId.startsWith("agent:")) {
80
+ return "agent";
81
+ }
82
+ if (actorId.startsWith("@")) {
83
+ return "human";
84
+ }
85
+ }
86
+
87
+ return "human";
88
+ }
89
+
90
+ function normalizeContextItems(values) {
91
+ if (!Array.isArray(values)) {
92
+ return [];
93
+ }
94
+ return values.map((value) => String(value).trim()).filter(Boolean);
95
+ }
96
+
43
97
  function printIssues(issues) {
44
98
  for (const issue of issues) {
45
- const location = issue.ticket_path ?? issue.log ?? "";
99
+ const location = issue.ticket_path ?? issue.log ?? issue.config_path ?? "";
46
100
  process.stdout.write(`${String(issue.severity ?? "?").toUpperCase()}: ${issue.message} (${location})\n`);
47
101
  }
48
102
  }
@@ -65,11 +119,14 @@ function buildRepairsFromIssues(issues, options = {}) {
65
119
  for (const issue of issues) {
66
120
  const code = issue.code;
67
121
  const ticketPath = issue.ticket_path;
68
- if (!ticketPath) {
122
+ const logLocation = issue.log;
123
+ const logPath = logLocation ? String(logLocation).replace(/:\d+$/, "") : null;
124
+ if (!ticketPath && !logPath) {
69
125
  continue;
70
126
  }
71
127
 
72
- const key = `${code}:${ticketPath}`;
128
+ const targetPath = ticketPath ?? logPath;
129
+ const key = `${code}:${targetPath}`;
73
130
  if (seen.has(key)) {
74
131
  continue;
75
132
  }
@@ -85,10 +142,31 @@ function buildRepairsFromIssues(issues, options = {}) {
85
142
  id: nextId,
86
143
  enabled: false,
87
144
  issue_ids: [issue.id ?? ""],
88
- ticket_path: ticketPath,
89
145
  };
146
+ if (ticketPath) {
147
+ base.ticket_path = ticketPath;
148
+ }
149
+ if (logPath) {
150
+ base.log_path = logPath;
151
+ }
90
152
 
91
- if (code === "MISSING_SECTION") {
153
+ if (["LOG_EVENT_TYPE_MISSING", "LOG_EVENT_TYPE_INVALID"].includes(code)) {
154
+ repairs.push({
155
+ ...base,
156
+ safe: true,
157
+ action: "set_log_event_type",
158
+ params: {},
159
+ optional: false,
160
+ });
161
+ } else if (["CONTEXT_INVALID", "CONTEXT_EMPTY", "CONTEXT_ENTRY_INVALID", "CONTEXT_MISSING"].includes(code)) {
162
+ repairs.push({
163
+ ...base,
164
+ safe: true,
165
+ action: "normalize_log_context",
166
+ params: {},
167
+ optional: false,
168
+ });
169
+ } else if (code === "MISSING_SECTION") {
92
170
  repairs.push({ ...base, safe: true, action: "add_sections", params: {}, optional: false });
93
171
  } else if (["VERSION_MISSING", "VERSION_INVALID"].includes(code)) {
94
172
  repairs.push({
@@ -166,90 +244,7 @@ function buildRepairsFromIssues(issues, options = {}) {
166
244
  return repairs;
167
245
  }
168
246
 
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) {
247
+ function renderMermaid(graph, includeRelated, timestamp, view) {
253
248
  const statusClasses = {
254
249
  todo: "fill:#ddd,stroke:#999",
255
250
  doing: "fill:#d0e7ff,stroke:#3b82f6",
@@ -259,7 +254,7 @@ function renderMermaid(graph, includeRelated, timestamp) {
259
254
  };
260
255
 
261
256
  const lines = [
262
- "# Ticket dependency graph",
257
+ `# Ticket ${view} graph`,
263
258
  `_Generated at ${timestamp} UTC_`,
264
259
  "",
265
260
  "```mermaid",
@@ -286,7 +281,8 @@ function renderMermaid(graph, includeRelated, timestamp) {
286
281
  if (!source || !target) {
287
282
  continue;
288
283
  }
289
- lines.push(` ${source} --> ${target}`);
284
+ const connector = edge.type === "contains" ? "-.->" : "-->";
285
+ lines.push(` ${source} ${connector}|${edge.type}| ${target}`);
290
286
  }
291
287
 
292
288
  for (const [status, style] of Object.entries(statusClasses)) {
@@ -333,8 +329,8 @@ function renderDot(graph, includeRelated) {
333
329
  if (!source || !target) {
334
330
  continue;
335
331
  }
336
- const style = edge.type === "related" ? "dashed" : "solid";
337
- lines.push(` ${source} -> ${target} [style=${style}];`);
332
+ const style = ["related", "contains"].includes(edge.type) ? "dashed" : "solid";
333
+ lines.push(` ${source} -> ${target} [style=${style}, label="${edge.type}"];`);
338
334
  }
339
335
 
340
336
  lines.push("}");
@@ -657,6 +653,7 @@ function generateExampleTickets() {
657
653
  status: "doing",
658
654
  priority: "high",
659
655
  labels: ["epic", "planning"],
656
+ planning: { node_type: "group", lane: "build", rank: 1, horizon: "current" },
660
657
  assignment: { mode: "mixed", owner: "team:core" },
661
658
  related: ["backend", "frontend", "testing", "docs", "release"],
662
659
  agent_limits: {
@@ -678,6 +675,7 @@ function generateExampleTickets() {
678
675
  logs: [
679
676
  {
680
677
  summary: "Epic created and split into child tickets.",
678
+ context: ["Parent planning context for Feature Alpha", "Child tickets were split for parallel execution"],
681
679
  tickets_created: ["backend", "frontend", "testing", "docs"],
682
680
  next_steps: ["Coordinate release window", "Monitor blockers"],
683
681
  },
@@ -689,6 +687,7 @@ function generateExampleTickets() {
689
687
  status: "doing",
690
688
  priority: "high",
691
689
  labels: ["backend", "api"],
690
+ planning: { node_type: "work", group_ids: ["parent"], lane: "build", rank: 1, horizon: "current", precedes: ["frontend", "testing"] },
692
691
  assignment: { mode: "agent_only", owner: "agent:codex" },
693
692
  dependencies: ["parent"],
694
693
  blocks: ["frontend", "testing", "release"],
@@ -709,7 +708,12 @@ function generateExampleTickets() {
709
708
  summary: "Scaffolded API and outlined endpoints.",
710
709
  decisions: ["Using UUID primary keys", "Respond with JSON:API style"],
711
710
  created_from: "parent",
712
- context_carried_over: ["Acceptance criteria from parent", "Release target"],
711
+ context: ["Acceptance criteria from parent", "Release target"],
712
+ },
713
+ {
714
+ summary: "Agent claimed backend work.",
715
+ event_type: "claim",
716
+ claim: { action: "acquire", holder_id: "agent:codex", holder_type: "agent", ttl_minutes: 60 },
713
717
  },
714
718
  ],
715
719
  },
@@ -719,6 +723,7 @@ function generateExampleTickets() {
719
723
  status: "todo",
720
724
  priority: "medium",
721
725
  labels: ["frontend", "ui"],
726
+ planning: { node_type: "work", group_ids: ["parent"], lane: "build", rank: 2, horizon: "current" },
722
727
  dependencies: ["backend"],
723
728
  related: ["testing"],
724
729
  verification: { commands: ["npm test", "npm run lint"] },
@@ -732,7 +737,7 @@ function generateExampleTickets() {
732
737
  summary: "Waiting on API responses to stabilize.",
733
738
  blockers: ["Backend contract not finalized"],
734
739
  created_from: "parent",
735
- context_carried_over: ["Design mocks v1.2", "API schema draft"],
740
+ context: ["Design mocks v1.2", "API schema draft"],
736
741
  },
737
742
  ],
738
743
  },
@@ -742,6 +747,7 @@ function generateExampleTickets() {
742
747
  status: "todo",
743
748
  priority: "medium",
744
749
  labels: ["qa"],
750
+ planning: { node_type: "work", group_ids: ["parent"], lane: "verify", rank: 1, horizon: "current" },
745
751
  dependencies: ["backend", "frontend"],
746
752
  verification: { commands: ["npm test"] },
747
753
  body: {
@@ -754,7 +760,7 @@ function generateExampleTickets() {
754
760
  summary: "Outlined E2E scenarios to automate.",
755
761
  next_steps: ["Set up test data fixtures"],
756
762
  created_from: "parent",
757
- context_carried_over: ["Frontend flow chart", "Backend contract v1"],
763
+ context: ["Frontend flow chart", "Backend contract v1"],
758
764
  },
759
765
  ],
760
766
  },
@@ -764,6 +770,7 @@ function generateExampleTickets() {
764
770
  status: "todo",
765
771
  priority: "low",
766
772
  labels: ["docs"],
773
+ planning: { node_type: "work", group_ids: ["parent"], lane: "launch", rank: 2, horizon: "next" },
767
774
  dependencies: ["testing"],
768
775
  verification: { commands: ["npm run lint:docs"] },
769
776
  body: {
@@ -776,7 +783,7 @@ function generateExampleTickets() {
776
783
  summary: "Preparing outline; waiting on test results.",
777
784
  blockers: ["Integration tests pending"],
778
785
  created_from: "parent",
779
- context_carried_over: ["Feature overview", "Known limitations"],
786
+ context: ["Feature overview", "Known limitations"],
780
787
  },
781
788
  ],
782
789
  },
@@ -786,6 +793,7 @@ function generateExampleTickets() {
786
793
  status: "todo",
787
794
  priority: "high",
788
795
  labels: ["release"],
796
+ planning: { node_type: "checkpoint", group_ids: ["parent"], lane: "launch", rank: 1, horizon: "current" },
789
797
  dependencies: ["testing"],
790
798
  blocks: ["bugfix"],
791
799
  verification: { commands: ["npx @picoai/tickets validate"] },
@@ -797,6 +805,7 @@ function generateExampleTickets() {
797
805
  logs: [
798
806
  {
799
807
  summary: "Drafted release checklist; waiting on test green.",
808
+ context: ["Release checklist draft", "Waiting on integration test completion"],
800
809
  next_steps: ["Book release window"],
801
810
  },
802
811
  ],
@@ -804,9 +813,11 @@ function generateExampleTickets() {
804
813
  {
805
814
  key: "bugfix",
806
815
  title: "Bugfix: address regression found during Alpha",
807
- status: "blocked",
816
+ status: "canceled",
808
817
  priority: "high",
809
818
  labels: ["bug", "regression"],
819
+ planning: { node_type: "work", group_ids: ["parent"], lane: "build", rank: 3, horizon: "current" },
820
+ resolution: "dropped",
810
821
  dependencies: ["backend"],
811
822
  related: ["testing"],
812
823
  verification: { commands: ["npm test"] },
@@ -817,8 +828,9 @@ function generateExampleTickets() {
817
828
  },
818
829
  logs: [
819
830
  {
820
- summary: "Blocked until backend fix lands.",
821
- blockers: ["Awaiting backend deployment"],
831
+ summary: "Dropped after mitigation in backend workstream.",
832
+ context: ["Regression repro identified", "Awaiting backend deployment before retry"],
833
+ decisions: ["Folded remediation into backend ticket"],
822
834
  },
823
835
  ],
824
836
  },
@@ -847,6 +859,16 @@ function generateExampleTickets() {
847
859
  if (spec.assignment) {
848
860
  frontMatter.assignment = spec.assignment;
849
861
  }
862
+ if (spec.planning) {
863
+ frontMatter.planning = {
864
+ ...spec.planning,
865
+ group_ids: spec.planning.group_ids?.map((value) => ids[value]) ?? spec.planning.group_ids,
866
+ precedes: spec.planning.precedes?.map((value) => ids[value]) ?? spec.planning.precedes,
867
+ };
868
+ }
869
+ if (spec.resolution) {
870
+ frontMatter.resolution = spec.resolution;
871
+ }
850
872
  for (const relationshipKey of ["dependencies", "blocks", "related"]) {
851
873
  if (spec[relationshipKey]) {
852
874
  frontMatter[relationshipKey] = spec[relationshipKey].map((value) => ids[value]);
@@ -886,16 +908,18 @@ function generateExampleTickets() {
886
908
  actor_type: "agent",
887
909
  actor_id: "tickets-init",
888
910
  summary: logSpec.summary,
911
+ event_type: logSpec.event_type ?? "work",
889
912
  written_by: "tickets",
890
913
  };
891
914
 
892
915
  for (const key of [
916
+ "context",
917
+ "claim",
893
918
  "decisions",
894
919
  "next_steps",
895
920
  "blockers",
896
921
  "tickets_created",
897
922
  "created_from",
898
- "context_carried_over",
899
923
  ]) {
900
924
  if (!(key in logSpec)) {
901
925
  continue;
@@ -905,6 +929,12 @@ function generateExampleTickets() {
905
929
  logEntry[key] = logSpec[key].map((value) => ids[value]);
906
930
  } else if (key === "created_from") {
907
931
  logEntry[key] = ids[logSpec[key]] ?? logSpec[key];
932
+ } else if (key === "claim") {
933
+ logEntry.claim = {
934
+ ...logSpec.claim,
935
+ claim_id: newUuidv7().toLowerCase(),
936
+ expires_at: iso8601(new Date(now.getTime() + (logSpec.claim.ttl_minutes ?? 60) * 60 * 1000)),
937
+ };
908
938
  } else {
909
939
  logEntry[key] = logSpec[key];
910
940
  }
@@ -923,6 +953,8 @@ async function cmdInit(options) {
923
953
  const apply = Boolean(options.apply);
924
954
 
925
955
  syncTicketsMd(root, apply);
956
+ syncRepoConfig(root);
957
+ syncRepoSkill(root, apply);
926
958
 
927
959
  const agentsPath = path.join(root, "AGENTS_EXAMPLE.md");
928
960
  const agentsTemplatePath = path.join(".tickets", "spec", "AGENTS_EXAMPLE.md");
@@ -935,8 +967,18 @@ async function cmdInit(options) {
935
967
  const versionDir = path.join(repoBaseDir, "version");
936
968
  ensureDir(versionDir);
937
969
 
938
- const specPath = path.join(versionDir, "20260205-tickets-spec.md");
939
- writeTemplateFile(specPath, path.join(".tickets", "spec", "version", "20260205-tickets-spec.md"), apply);
970
+ const currentSpecPath = path.join(versionDir, "20260317-tickets-spec.md");
971
+ writeTemplateFile(currentSpecPath, path.join(".tickets", "spec", "version", "20260317-tickets-spec.md"), apply);
972
+
973
+ const previousCurrentSpecPath = path.join(versionDir, "20260311-tickets-spec.md");
974
+ writeTemplateFile(
975
+ previousCurrentSpecPath,
976
+ path.join(".tickets", "spec", "version", "20260311-tickets-spec.md"),
977
+ apply,
978
+ );
979
+
980
+ const previousSpecPath = path.join(versionDir, "20260205-tickets-spec.md");
981
+ writeTemplateFile(previousSpecPath, path.join(".tickets", "spec", "version", "20260205-tickets-spec.md"), apply);
940
982
 
941
983
  const proposedPath = path.join(versionDir, "PROPOSED-tickets-spec.md");
942
984
  writeTemplateFile(proposedPath, path.join(".tickets", "spec", "version", "PROPOSED-tickets-spec.md"), apply);
@@ -951,6 +993,7 @@ async function cmdInit(options) {
951
993
 
952
994
  async function cmdNew(options) {
953
995
  ensureDir(ticketsDir());
996
+ const profile = loadWorkflowProfile();
954
997
  const ticketId = newUuidv7().toLowerCase();
955
998
  const ticketDir = path.join(ticketsDir(), ticketId);
956
999
  ensureDir(path.join(ticketDir, "logs"));
@@ -1007,6 +1050,17 @@ async function cmdNew(options) {
1007
1050
  if (options.verificationCommands?.length) {
1008
1051
  frontMatter.verification = { commands: options.verificationCommands };
1009
1052
  }
1053
+ frontMatter.planning = {
1054
+ node_type: options.nodeType || profile.defaults?.planning?.node_type || "work",
1055
+ group_ids: options.groupIds?.length ? options.groupIds : [],
1056
+ lane: options.lane ?? null,
1057
+ rank: options.rank ?? null,
1058
+ horizon: options.horizon ?? null,
1059
+ precedes: options.precedes?.length ? options.precedes : [],
1060
+ };
1061
+ if (options.resolution) {
1062
+ frontMatter.resolution = options.resolution;
1063
+ }
1010
1064
 
1011
1065
  const body = [
1012
1066
  "# Ticket",
@@ -1033,6 +1087,8 @@ async function cmdValidate(options) {
1033
1087
  const ticketPaths = collectTicketPaths(options.ticket);
1034
1088
  const issues = [];
1035
1089
 
1090
+ issues.push(...validateRepoConfig(repoRoot()));
1091
+
1036
1092
  for (const ticketPath of ticketPaths) {
1037
1093
  const [ticketIssues] = validateTicket(ticketPath, options.allFields);
1038
1094
  issues.push(...ticketIssues);
@@ -1085,28 +1141,37 @@ async function cmdValidate(options) {
1085
1141
  async function cmdStatus(options) {
1086
1142
  const ticketPath = resolveTicketPath(options.ticket);
1087
1143
  const [frontMatter, body] = loadTicket(ticketPath);
1144
+ const previousStatus = frontMatter.status;
1145
+ const actorId = resolveActorId(options.actorId);
1146
+ const actorType = resolveActorType(options.actorType, actorId);
1147
+ const context = normalizeContextItems(options.context);
1088
1148
 
1089
1149
  frontMatter.status = options.status;
1090
1150
  writeTicket(ticketPath, frontMatter, body);
1091
1151
 
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);
1152
+ const runId = options.runId || newUuidv7();
1153
+ const runStarted = (options.runStarted || isoBasic(nowUtc())).replaceAll(" ", "");
1154
+ const entry = {
1155
+ version: FORMAT_VERSION,
1156
+ version_url: FORMAT_VERSION_URL,
1157
+ ts: iso8601(nowUtc()),
1158
+ run_started: runStarted,
1159
+ actor_type: actorType,
1160
+ actor_id: actorId,
1161
+ summary:
1162
+ previousStatus === options.status
1163
+ ? `Status reaffirmed as ${options.status}`
1164
+ : `Status changed from ${previousStatus ?? "unknown"} to ${options.status}`,
1165
+ event_type: "status",
1166
+ written_by: "tickets",
1167
+ };
1168
+ if (context.length > 0) {
1169
+ entry.context = context;
1108
1170
  }
1109
1171
 
1172
+ const logPath = path.join(path.dirname(ticketPath), "logs", `${runStarted}-${runId}.jsonl`);
1173
+ appendJsonl(logPath, entry);
1174
+
1110
1175
  return 0;
1111
1176
  }
1112
1177
 
@@ -1114,16 +1179,26 @@ async function cmdLog(options) {
1114
1179
  const ticketPath = resolveTicketPath(options.ticket);
1115
1180
  const runId = options.runId || newUuidv7();
1116
1181
  const runStarted = (options.runStarted || isoBasic(nowUtc())).replaceAll(" ", "");
1182
+ const actorId = resolveActorId(options.actorId);
1183
+ const actorType = resolveActorType(options.actorType, actorId);
1184
+ const context = normalizeContextItems(options.context);
1185
+ if (options.machine && context.length === 0) {
1186
+ throw new Error("Machine-written work logs require at least one --context item");
1187
+ }
1117
1188
 
1118
1189
  const entry = {
1119
1190
  version: FORMAT_VERSION,
1120
1191
  version_url: FORMAT_VERSION_URL,
1121
1192
  ts: iso8601(nowUtc()),
1122
1193
  run_started: runStarted,
1123
- actor_type: options.actorType,
1124
- actor_id: options.actorId,
1194
+ actor_type: actorType,
1195
+ actor_id: actorId,
1125
1196
  summary: options.summary,
1197
+ event_type: "work",
1126
1198
  };
1199
+ if (context.length > 0) {
1200
+ entry.context = context;
1201
+ }
1127
1202
 
1128
1203
  if (options.machine) {
1129
1204
  entry.written_by = "tickets";
@@ -1146,9 +1221,6 @@ async function cmdLog(options) {
1146
1221
  if (options.createdFrom) {
1147
1222
  entry.created_from = options.createdFrom;
1148
1223
  }
1149
- if (options.contextCarriedOver?.length) {
1150
- entry.context_carried_over = options.contextCarriedOver;
1151
- }
1152
1224
  if (options.verificationCommands?.length || options.verificationResults) {
1153
1225
  entry.verification = {
1154
1226
  commands: options.verificationCommands || [],
@@ -1169,6 +1241,13 @@ async function cmdList(options) {
1169
1241
  owner: options.owner,
1170
1242
  label: options.label,
1171
1243
  text: options.text,
1244
+ nodeType: options.nodeType,
1245
+ group: options.group,
1246
+ lane: options.lane,
1247
+ horizon: options.horizon,
1248
+ claimed: Boolean(options.claimed),
1249
+ claimedBy: options.claimedBy,
1250
+ ready: Boolean(options.ready),
1172
1251
  });
1173
1252
 
1174
1253
  if (options.json) {
@@ -1181,7 +1260,20 @@ async function cmdList(options) {
1181
1260
  return 0;
1182
1261
  }
1183
1262
 
1184
- const headers = ["id", "title", "status", "priority", "owner", "mode", "last_updated"];
1263
+ const headers = [
1264
+ "id",
1265
+ "title",
1266
+ "status",
1267
+ "priority",
1268
+ "node_type",
1269
+ "lane",
1270
+ "rank",
1271
+ "horizon",
1272
+ "ready",
1273
+ "owner",
1274
+ "mode",
1275
+ "last_updated",
1276
+ ];
1185
1277
  process.stdout.write(`${headers.join(" | ")}\n`);
1186
1278
  for (const row of rows) {
1187
1279
  process.stdout.write(`${headers.map((key) => String(row[key] ?? "")).join(" | ")}\n`);
@@ -1190,6 +1282,119 @@ async function cmdList(options) {
1190
1282
  return 0;
1191
1283
  }
1192
1284
 
1285
+ async function cmdPlan(options) {
1286
+ const summary = buildPlanSummary({
1287
+ group: options.group ?? options.root ?? null,
1288
+ horizon: options.horizon ?? null,
1289
+ });
1290
+
1291
+ if (options.format === "json") {
1292
+ process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
1293
+ return 0;
1294
+ }
1295
+
1296
+ process.stdout.write("Ready queue\n");
1297
+ for (const row of summary.ready) {
1298
+ process.stdout.write(
1299
+ `${row.id} | ${row.title} | lane=${row.lane ?? ""} | rank=${row.rank ?? ""} | horizon=${row.horizon ?? ""}\n`,
1300
+ );
1301
+ }
1302
+ process.stdout.write("\nGroups\n");
1303
+ for (const group of summary.groups) {
1304
+ const rollup = group.rollup ?? {};
1305
+ process.stdout.write(
1306
+ `${group.id} | ${group.title} | ${group.node_type} | ${rollup.done_completed ?? 0}/${rollup.active_leaf ?? 0} complete | merged=${rollup.merged ?? 0} | dropped=${rollup.dropped ?? 0}\n`,
1307
+ );
1308
+ }
1309
+
1310
+ return 0;
1311
+ }
1312
+
1313
+ async function cmdClaim(options) {
1314
+ const ticketPath = resolveTicketPath(options.ticket);
1315
+ const logsDir = path.join(path.dirname(ticketPath), "logs");
1316
+ ensureDir(logsDir);
1317
+ const profile = loadWorkflowProfile();
1318
+
1319
+ const actorId = resolveActorId(options.actorId);
1320
+ const actorType = resolveActorType(options.actorType, actorId);
1321
+ const events = loadClaimEvents(logsDir);
1322
+ const activeClaim = deriveActiveClaim(events, nowUtc());
1323
+ const runId = options.runId || newUuidv7();
1324
+ const runStarted = (options.runStarted || isoBasic(nowUtc())).replaceAll(" ", "");
1325
+ const ttlMinutes = options.ttlMinutes || profile.defaults?.claims?.ttl_minutes || DEFAULT_CLAIM_TTL_MINUTES;
1326
+ const now = nowUtc();
1327
+
1328
+ let action;
1329
+ let summary;
1330
+ let claimId = newUuidv7().toLowerCase();
1331
+ let supersedesClaimId = null;
1332
+
1333
+ if (options.release) {
1334
+ if (!activeClaim) {
1335
+ process.stdout.write("No active claim.\n");
1336
+ return 1;
1337
+ }
1338
+ if (activeClaim.holder_id !== actorId && !options.force) {
1339
+ process.stdout.write(`Ticket is claimed by ${activeClaim.holder_id} until ${activeClaim.expires_at}.\n`);
1340
+ return 1;
1341
+ }
1342
+ if (activeClaim.holder_id !== actorId && options.force && !options.reason) {
1343
+ throw new Error("Forced claim release requires --reason");
1344
+ }
1345
+ action = "release";
1346
+ claimId = activeClaim.claim_id;
1347
+ summary = `Released claim ${claimId}`;
1348
+ } else if (activeClaim && activeClaim.holder_id === actorId) {
1349
+ action = "renew";
1350
+ claimId = activeClaim.claim_id;
1351
+ summary = `Renewed claim ${claimId}`;
1352
+ } else if (activeClaim && activeClaim.holder_id !== actorId) {
1353
+ if (!options.force) {
1354
+ process.stdout.write(`Ticket is claimed by ${activeClaim.holder_id} until ${activeClaim.expires_at}.\n`);
1355
+ return 1;
1356
+ }
1357
+ if (!options.reason) {
1358
+ throw new Error("Forced claim override requires --reason");
1359
+ }
1360
+ action = "override";
1361
+ supersedesClaimId = activeClaim.claim_id;
1362
+ summary = `Overrode claim ${activeClaim.claim_id}`;
1363
+ } else {
1364
+ action = "acquire";
1365
+ summary = `Acquired claim ${claimId}`;
1366
+ }
1367
+
1368
+ const entry = {
1369
+ version: FORMAT_VERSION,
1370
+ version_url: FORMAT_VERSION_URL,
1371
+ ts: iso8601(now),
1372
+ run_started: runStarted,
1373
+ actor_type: actorType,
1374
+ actor_id: actorId,
1375
+ summary,
1376
+ event_type: "claim",
1377
+ written_by: "tickets",
1378
+ claim: {
1379
+ action,
1380
+ claim_id: claimId,
1381
+ holder_id: actorId,
1382
+ holder_type: actorType,
1383
+ reason: options.reason ?? "",
1384
+ supersedes_claim_id: supersedesClaimId,
1385
+ },
1386
+ };
1387
+
1388
+ if (action !== "release") {
1389
+ entry.claim.ttl_minutes = ttlMinutes;
1390
+ entry.claim.expires_at = iso8601(new Date(now.getTime() + ttlMinutes * 60 * 1000));
1391
+ }
1392
+
1393
+ appendJsonl(path.join(logsDir, `${runStarted}-${runId}.jsonl`), entry);
1394
+ process.stdout.write(`${summary}\n`);
1395
+ return 0;
1396
+ }
1397
+
1193
1398
  async function cmdRepair(options) {
1194
1399
  const nonInteractive = options.nonInteractive;
1195
1400
 
@@ -1226,6 +1431,24 @@ async function cmdRepair(options) {
1226
1431
  autoEnableSafe: !options.interactive,
1227
1432
  }),
1228
1433
  );
1434
+
1435
+ const logsDir = path.join(path.dirname(ticketPath), "logs");
1436
+ if (!fs.existsSync(logsDir)) {
1437
+ continue;
1438
+ }
1439
+ const logFiles = fs
1440
+ .readdirSync(logsDir, { withFileTypes: true })
1441
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
1442
+ .map((entry) => path.join(logsDir, entry.name))
1443
+ .sort((a, b) => a.localeCompare(b));
1444
+ for (const logFile of logFiles) {
1445
+ repairs.push(
1446
+ ...buildRepairsFromIssues(validateRunLog(logFile, false), {
1447
+ includeOptional: options.allFields,
1448
+ autoEnableSafe: !options.interactive,
1449
+ }),
1450
+ );
1451
+ }
1229
1452
  }
1230
1453
 
1231
1454
  const changes = options.interactive
@@ -1243,7 +1466,12 @@ async function cmdRepair(options) {
1243
1466
  }
1244
1467
 
1245
1468
  async function cmdGraph(options) {
1246
- const graph = loadTicketGraph(options.ticket);
1469
+ const snapshot = buildPlanningSnapshot();
1470
+ const graph = buildGraphData(snapshot, {
1471
+ ticket: options.ticket,
1472
+ view: options.view,
1473
+ includeRelated: options.related,
1474
+ });
1247
1475
  if (graph.nodes.length === 0) {
1248
1476
  process.stdout.write("No tickets found.\n");
1249
1477
  return 1;
@@ -1254,8 +1482,8 @@ async function cmdGraph(options) {
1254
1482
 
1255
1483
  const timestamp = isoBasic(nowUtc());
1256
1484
  const base = options.ticket
1257
- ? `dependencies_for_${graph.root_id || "subset"}`
1258
- : "dependencies";
1485
+ ? `${options.view}_for_${graph.root_id || "subset"}`
1486
+ : options.view;
1259
1487
  const ext = { mermaid: "md", dot: "dot", json: "json" }[options.format];
1260
1488
  const outPath = options.output
1261
1489
  ? path.resolve(repoRoot(), options.output)
@@ -1267,7 +1495,7 @@ async function cmdGraph(options) {
1267
1495
  } else if (options.format === "dot") {
1268
1496
  fs.writeFileSync(outPath, renderDot(graph, options.related));
1269
1497
  } else {
1270
- fs.writeFileSync(outPath, renderMermaid(graph, options.related, timestamp));
1498
+ fs.writeFileSync(outPath, renderMermaid(graph, options.related, timestamp, options.view));
1271
1499
  }
1272
1500
 
1273
1501
  process.stdout.write(`${outPath}\n`);
@@ -1308,6 +1536,13 @@ export async function run(argv = process.argv.slice(2)) {
1308
1536
  .option("--checkpoint-every-minutes <minutes>")
1309
1537
  .option("--verification-command <command>", "Verification command", collectOption, [])
1310
1538
  .option("--created-at <timestamp>")
1539
+ .option("--node-type <nodeType>")
1540
+ .option("--group-id <groupId>", "Group membership ticket id", collectOption, [])
1541
+ .option("--lane <lane>")
1542
+ .option("--rank <rank>")
1543
+ .option("--horizon <horizon>")
1544
+ .option("--precedes <ticketId>", "Sequence successor ticket id", collectOption, [])
1545
+ .option("--resolution <resolution>")
1311
1546
  .action(async (options) => {
1312
1547
  if (!STATUS_VALUES.includes(options.status)) {
1313
1548
  throw new Error(`Invalid --status. Use one of: ${STATUS_VALUES.join(", ")}`);
@@ -1320,6 +1555,21 @@ export async function run(argv = process.argv.slice(2)) {
1320
1555
  `Invalid --assignment-mode. Use one of: ${ASSIGNMENT_MODE_VALUES.join(", ")}`,
1321
1556
  );
1322
1557
  }
1558
+ if (options.nodeType && !PLANNING_NODE_TYPES.includes(options.nodeType)) {
1559
+ throw new Error(`Invalid --node-type. Use one of: ${PLANNING_NODE_TYPES.join(", ")}`);
1560
+ }
1561
+ if (options.resolution && !RESOLUTION_VALUES.includes(options.resolution)) {
1562
+ throw new Error(`Invalid --resolution. Use one of: ${RESOLUTION_VALUES.join(", ")}`);
1563
+ }
1564
+ if (options.rank) {
1565
+ const rank = Number.parseInt(options.rank, 10);
1566
+ if (!Number.isInteger(rank) || rank <= 0) {
1567
+ throw new Error("Invalid --rank. Use a positive integer");
1568
+ }
1569
+ }
1570
+ if (options.resolution && !["done", "canceled"].includes(options.status)) {
1571
+ throw new Error("Resolution requires terminal status done or canceled");
1572
+ }
1323
1573
  process.exitCode = await cmdNew({
1324
1574
  title: options.title,
1325
1575
  status: options.status,
@@ -1340,6 +1590,13 @@ export async function run(argv = process.argv.slice(2)) {
1340
1590
  : undefined,
1341
1591
  verificationCommands: options.verificationCommand,
1342
1592
  createdAt: options.createdAt,
1593
+ nodeType: options.nodeType,
1594
+ groupIds: options.groupId,
1595
+ lane: options.lane,
1596
+ rank: options.rank ? Number.parseInt(options.rank, 10) : null,
1597
+ horizon: options.horizon,
1598
+ precedes: options.precedes,
1599
+ resolution: options.resolution,
1343
1600
  });
1344
1601
  });
1345
1602
 
@@ -1364,17 +1621,24 @@ export async function run(argv = process.argv.slice(2)) {
1364
1621
  .description("Update ticket status")
1365
1622
  .requiredOption("--ticket <ticket>")
1366
1623
  .requiredOption("--status <status>")
1367
- .option("--log", "Write a status-change log entry")
1624
+ .option("--actor-type <actorType>")
1625
+ .option("--actor-id <actorId>")
1626
+ .option("--context <items...>")
1368
1627
  .option("--run-id <runId>")
1369
1628
  .option("--run-started <runStarted>")
1370
1629
  .action(async (options) => {
1371
1630
  if (!STATUS_VALUES.includes(options.status)) {
1372
1631
  throw new Error(`Invalid --status. Use one of: ${STATUS_VALUES.join(", ")}`);
1373
1632
  }
1633
+ if (options.actorType && !isValidActorType(options.actorType)) {
1634
+ throw new Error("Invalid --actor-type. Use one of: human, agent");
1635
+ }
1374
1636
  process.exitCode = await cmdStatus({
1375
1637
  ticket: options.ticket,
1376
1638
  status: options.status,
1377
- log: Boolean(options.log),
1639
+ actorType: options.actorType,
1640
+ actorId: options.actorId,
1641
+ context: options.context,
1378
1642
  runId: options.runId,
1379
1643
  runStarted: options.runStarted,
1380
1644
  });
@@ -1386,8 +1650,8 @@ export async function run(argv = process.argv.slice(2)) {
1386
1650
  .requiredOption("--ticket <ticket>")
1387
1651
  .option("--run-id <runId>")
1388
1652
  .option("--run-started <runStarted>")
1389
- .requiredOption("--actor-type <actorType>")
1390
- .requiredOption("--actor-id <actorId>")
1653
+ .option("--actor-type <actorType>")
1654
+ .option("--actor-id <actorId>")
1391
1655
  .requiredOption("--summary <summary>")
1392
1656
  .option("--machine")
1393
1657
  .option("--changes <files...>")
@@ -1396,11 +1660,11 @@ export async function run(argv = process.argv.slice(2)) {
1396
1660
  .option("--blockers <blockers...>")
1397
1661
  .option("--tickets-created <tickets...>")
1398
1662
  .option("--created-from <ticketId>")
1399
- .option("--context-carried-over <items...>")
1663
+ .option("--context <items...>")
1400
1664
  .option("--verification-commands <commands...>")
1401
1665
  .option("--verification-results <results>")
1402
1666
  .action(async (options) => {
1403
- if (!["human", "agent"].includes(options.actorType)) {
1667
+ if (options.actorType && !isValidActorType(options.actorType)) {
1404
1668
  throw new Error("Invalid --actor-type. Use one of: human, agent");
1405
1669
  }
1406
1670
  process.exitCode = await cmdLog({
@@ -1417,7 +1681,7 @@ export async function run(argv = process.argv.slice(2)) {
1417
1681
  blockers: options.blockers,
1418
1682
  ticketsCreated: options.ticketsCreated,
1419
1683
  createdFrom: options.createdFrom,
1420
- contextCarriedOver: options.contextCarriedOver,
1684
+ context: options.context,
1421
1685
  verificationCommands: options.verificationCommands,
1422
1686
  verificationResults: options.verificationResults,
1423
1687
  });
@@ -1432,11 +1696,32 @@ export async function run(argv = process.argv.slice(2)) {
1432
1696
  .option("--owner <owner>")
1433
1697
  .option("--label <label>")
1434
1698
  .option("--text <text>")
1699
+ .option("--node-type <nodeType>")
1700
+ .option("--group <ticketId>")
1701
+ .option("--lane <lane>")
1702
+ .option("--horizon <horizon>")
1703
+ .option("--claimed", "Only show claimed tickets")
1704
+ .option("--claimed-by <actorId>")
1705
+ .option("--ready", "Only show ready tickets")
1435
1706
  .option("--json", "JSON output")
1436
1707
  .action(async (options) => {
1437
1708
  process.exitCode = await cmdList(options);
1438
1709
  });
1439
1710
 
1711
+ program
1712
+ .command("plan")
1713
+ .description("Summarize portfolio rollups and ready work")
1714
+ .option("--root <ticket>")
1715
+ .option("--group <ticket>")
1716
+ .option("--horizon <horizon>")
1717
+ .option("--format <format>", "table | json", "table")
1718
+ .action(async (options) => {
1719
+ if (!["table", "json"].includes(options.format)) {
1720
+ throw new Error("Invalid --format. Use one of: table, json");
1721
+ }
1722
+ process.exitCode = await cmdPlan(options);
1723
+ });
1724
+
1440
1725
  program
1441
1726
  .command("repair")
1442
1727
  .description("Repair tickets")
@@ -1459,9 +1744,10 @@ export async function run(argv = process.argv.slice(2)) {
1459
1744
 
1460
1745
  program
1461
1746
  .command("graph")
1462
- .description("Dependency graph")
1747
+ .description("Ticket graph")
1463
1748
  .option("--ticket <ticket>")
1464
1749
  .option("--format <format>", "mermaid | dot | json", "mermaid")
1750
+ .option("--view <view>", "dependency | sequence | portfolio | all", "dependency")
1465
1751
  .option("--output <file>")
1466
1752
  .option("--related", "Include related edges")
1467
1753
  .option("--no-related", "Exclude related edges")
@@ -1469,14 +1755,53 @@ export async function run(argv = process.argv.slice(2)) {
1469
1755
  if (!["mermaid", "dot", "json"].includes(options.format)) {
1470
1756
  throw new Error("Invalid --format. Use one of: mermaid, dot, json");
1471
1757
  }
1758
+ if (!GRAPH_VIEW_VALUES.includes(options.view)) {
1759
+ throw new Error(`Invalid --view. Use one of: ${GRAPH_VIEW_VALUES.join(", ")}`);
1760
+ }
1472
1761
  process.exitCode = await cmdGraph({
1473
1762
  ticket: options.ticket,
1474
1763
  format: options.format,
1764
+ view: options.view,
1475
1765
  output: options.output,
1476
1766
  related: options.related,
1477
1767
  });
1478
1768
  });
1479
1769
 
1770
+ program
1771
+ .command("claim")
1772
+ .description("Acquire, renew, release, or override an advisory ticket claim")
1773
+ .requiredOption("--ticket <ticket>")
1774
+ .option("--actor-type <actorType>")
1775
+ .option("--actor-id <actorId>")
1776
+ .option("--run-id <runId>")
1777
+ .option("--run-started <runStarted>")
1778
+ .option("--ttl-minutes <minutes>")
1779
+ .option("--release", "Release the active claim")
1780
+ .option("--force", "Override an active claim held by another actor")
1781
+ .option("--reason <reason>")
1782
+ .action(async (options) => {
1783
+ if (options.actorType && !isValidActorType(options.actorType)) {
1784
+ throw new Error("Invalid --actor-type. Use one of: human, agent");
1785
+ }
1786
+ if (options.ttlMinutes) {
1787
+ const ttl = Number.parseInt(options.ttlMinutes, 10);
1788
+ if (!Number.isInteger(ttl) || ttl <= 0) {
1789
+ throw new Error("Invalid --ttl-minutes. Use a positive integer");
1790
+ }
1791
+ }
1792
+ process.exitCode = await cmdClaim({
1793
+ ticket: options.ticket,
1794
+ actorType: options.actorType,
1795
+ actorId: options.actorId,
1796
+ runId: options.runId,
1797
+ runStarted: options.runStarted,
1798
+ ttlMinutes: options.ttlMinutes ? Number.parseInt(options.ttlMinutes, 10) : undefined,
1799
+ release: Boolean(options.release),
1800
+ force: Boolean(options.force),
1801
+ reason: options.reason,
1802
+ });
1803
+ });
1804
+
1480
1805
  try {
1481
1806
  await program.parseAsync(argv, { from: "user" });
1482
1807
  } catch (error) {