@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/src/cli.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  FORMAT_VERSION,
13
13
  FORMAT_VERSION_URL,
14
14
  GRAPH_VIEW_VALUES,
15
+ LIST_SORT_VALUES,
15
16
  PLANNING_NODE_TYPES,
16
17
  PRIORITY_VALUES,
17
18
  RESOLUTION_VALUES,
@@ -19,11 +20,12 @@ import {
19
20
  } from "./lib/constants.js";
20
21
  import { deriveActiveClaim, loadClaimEvents } from "./lib/claims.js";
21
22
  import { loadWorkflowProfile, validateRepoConfig } from "./lib/config.js";
23
+ import { invalidatePlanningIndex, loadPlanningSnapshot, refreshPlanningIndexIfPresent } from "./lib/index.js";
22
24
  import { listTickets } from "./lib/listing.js";
23
- import { buildGraphData, buildPlanSummary, buildPlanningSnapshot } from "./lib/planning.js";
25
+ import { buildGraphData, buildPlanSummary } from "./lib/planning.js";
24
26
  import { syncRepoConfig, syncRepoSkill } from "./lib/projections.js";
25
27
  import { applyRepairs, loadIssuesFile, runInteractive } from "./lib/repair.js";
26
- import { collectTicketPaths, validateRunLog, validateTicket } from "./lib/validation.js";
28
+ import { collectTicketPaths, validatePlanningTopology, validateRunLog, validateTicket } from "./lib/validation.js";
27
29
  import {
28
30
  appendJsonl,
29
31
  ensureDir,
@@ -94,6 +96,61 @@ function normalizeContextItems(values) {
94
96
  return values.map((value) => String(value).trim()).filter(Boolean);
95
97
  }
96
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
+
97
154
  function printIssues(issues) {
98
155
  for (const issue of issues) {
99
156
  const location = issue.ticket_path ?? issue.log ?? issue.config_path ?? "";
@@ -266,7 +323,17 @@ function renderMermaid(graph, includeRelated, timestamp, view) {
266
323
  const nodeRef = `n${idx}`;
267
324
  nodeIds.set(node.id, nodeRef);
268
325
  const title = (node.title || node.id).replaceAll('"', '\\"');
269
- 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")}`;
270
337
  const status = (node.status || "todo").toLowerCase();
271
338
  lines.push(` ${nodeRef}["${label}"]:::status_${status}`);
272
339
  lines.push(` click ${nodeRef} "/.tickets/${node.id}/ticket.md" "_blank"`);
@@ -314,7 +381,17 @@ function renderDot(graph, includeRelated) {
314
381
  nodeIds.set(node.id, nodeRef);
315
382
  const status = (node.status || "todo").toLowerCase();
316
383
  const color = colors[status] ?? colors.todo;
317
- 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")}`;
318
395
  lines.push(
319
396
  ` ${nodeRef} [label="${label}", fillcolor="${color}", URL="/.tickets/${node.id}/ticket.md", target="_blank"];`,
320
397
  );
@@ -349,6 +426,14 @@ function renderJson(graph, includeRelated) {
349
426
  priority: node.priority,
350
427
  owner: node.owner,
351
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,
352
437
  href: `/.tickets/${node.id}/ticket.md`,
353
438
  })),
354
439
  };
@@ -967,8 +1052,8 @@ async function cmdInit(options) {
967
1052
  const versionDir = path.join(repoBaseDir, "version");
968
1053
  ensureDir(versionDir);
969
1054
 
970
- const currentSpecPath = path.join(versionDir, "20260317-tickets-spec.md");
971
- writeTemplateFile(currentSpecPath, path.join(".tickets", "spec", "version", "20260317-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);
972
1057
 
973
1058
  const previousCurrentSpecPath = path.join(versionDir, "20260311-tickets-spec.md");
974
1059
  writeTemplateFile(
@@ -987,6 +1072,7 @@ async function cmdInit(options) {
987
1072
  generateExampleTickets();
988
1073
  }
989
1074
 
1075
+ invalidatePlanningIndex();
990
1076
  process.stdout.write("Initialized.\n");
991
1077
  return 0;
992
1078
  }
@@ -994,9 +1080,40 @@ async function cmdInit(options) {
994
1080
  async function cmdNew(options) {
995
1081
  ensureDir(ticketsDir());
996
1082
  const profile = loadWorkflowProfile();
1083
+ const snapshot = loadPlanningSnapshot({ persist: false });
997
1084
  const ticketId = newUuidv7().toLowerCase();
998
1085
  const ticketDir = path.join(ticketsDir(), ticketId);
999
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
+ }
1000
1117
 
1001
1118
  const frontMatter = {
1002
1119
  id: ticketId,
@@ -1050,14 +1167,7 @@ async function cmdNew(options) {
1050
1167
  if (options.verificationCommands?.length) {
1051
1168
  frontMatter.verification = { commands: options.verificationCommands };
1052
1169
  }
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
- };
1170
+ frontMatter.planning = planning;
1061
1171
  if (options.resolution) {
1062
1172
  frontMatter.resolution = options.resolution;
1063
1173
  }
@@ -1079,12 +1189,14 @@ async function cmdNew(options) {
1079
1189
  ].join("\n");
1080
1190
 
1081
1191
  writeTicket(path.join(ticketDir, "ticket.md"), frontMatter, body);
1192
+ refreshPlanningIndexIfPresent();
1082
1193
  process.stdout.write(`${ticketId}\n`);
1083
1194
  return 0;
1084
1195
  }
1085
1196
 
1086
1197
  async function cmdValidate(options) {
1087
1198
  const ticketPaths = collectTicketPaths(options.ticket);
1199
+ const allTicketPaths = collectTicketPaths(null);
1088
1200
  const issues = [];
1089
1201
 
1090
1202
  issues.push(...validateRepoConfig(repoRoot()));
@@ -1109,6 +1221,8 @@ async function cmdValidate(options) {
1109
1221
  }
1110
1222
  }
1111
1223
 
1224
+ issues.push(...validatePlanningTopology(ticketPaths, allTicketPaths));
1225
+
1112
1226
  issues.forEach((issue, index) => {
1113
1227
  if (!issue.id) {
1114
1228
  issue.id = `I${String(index + 1).padStart(4, "0")}`;
@@ -1171,6 +1285,7 @@ async function cmdStatus(options) {
1171
1285
 
1172
1286
  const logPath = path.join(path.dirname(ticketPath), "logs", `${runStarted}-${runId}.jsonl`);
1173
1287
  appendJsonl(logPath, entry);
1288
+ refreshPlanningIndexIfPresent();
1174
1289
 
1175
1290
  return 0;
1176
1291
  }
@@ -1230,25 +1345,41 @@ async function cmdLog(options) {
1230
1345
 
1231
1346
  const logPath = path.join(path.dirname(ticketPath), "logs", `${runStarted}-${runId}.jsonl`);
1232
1347
  appendJsonl(logPath, entry);
1348
+ refreshPlanningIndexIfPresent();
1233
1349
  return 0;
1234
1350
  }
1235
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
+
1236
1359
  async function cmdList(options) {
1237
- const rows = listTickets({
1238
- status: options.status,
1239
- priority: options.priority,
1240
- mode: options.mode,
1241
- owner: options.owner,
1242
- label: options.label,
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),
1251
- });
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
+ );
1252
1383
 
1253
1384
  if (options.json) {
1254
1385
  process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
@@ -1270,20 +1401,18 @@ async function cmdList(options) {
1270
1401
  "rank",
1271
1402
  "horizon",
1272
1403
  "ready",
1404
+ "claim",
1273
1405
  "owner",
1274
- "mode",
1275
1406
  "last_updated",
1276
1407
  ];
1277
- process.stdout.write(`${headers.join(" | ")}\n`);
1278
- for (const row of rows) {
1279
- process.stdout.write(`${headers.map((key) => String(row[key] ?? "")).join(" | ")}\n`);
1280
- }
1408
+ renderTable(headers, rows.map((row) => ({ ...row, claim: row.claim_summary })));
1281
1409
 
1282
1410
  return 0;
1283
1411
  }
1284
1412
 
1285
1413
  async function cmdPlan(options) {
1286
- const summary = buildPlanSummary({
1414
+ const snapshot = loadPlanningSnapshot();
1415
+ const summary = buildPlanSummary(snapshot, {
1287
1416
  group: options.group ?? options.root ?? null,
1288
1417
  horizon: options.horizon ?? null,
1289
1418
  });
@@ -1293,19 +1422,41 @@ async function cmdPlan(options) {
1293
1422
  return 0;
1294
1423
  }
1295
1424
 
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) {
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) => {
1304
1445
  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
- }
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);
1309
1460
 
1310
1461
  return 0;
1311
1462
  }
@@ -1391,6 +1542,7 @@ async function cmdClaim(options) {
1391
1542
  }
1392
1543
 
1393
1544
  appendJsonl(path.join(logsDir, `${runStarted}-${runId}.jsonl`), entry);
1545
+ refreshPlanningIndexIfPresent();
1394
1546
  process.stdout.write(`${summary}\n`);
1395
1547
  return 0;
1396
1548
  }
@@ -1466,7 +1618,7 @@ async function cmdRepair(options) {
1466
1618
  }
1467
1619
 
1468
1620
  async function cmdGraph(options) {
1469
- const snapshot = buildPlanningSnapshot();
1621
+ const snapshot = loadPlanningSnapshot();
1470
1622
  const graph = buildGraphData(snapshot, {
1471
1623
  ticket: options.ticket,
1472
1624
  view: options.view,
@@ -1703,8 +1855,13 @@ export async function run(argv = process.argv.slice(2)) {
1703
1855
  .option("--claimed", "Only show claimed tickets")
1704
1856
  .option("--claimed-by <actorId>")
1705
1857
  .option("--ready", "Only show ready tickets")
1858
+ .option("--sort <sort>", "ready | priority | lane | rank | updated | title")
1859
+ .option("--reverse", "Reverse the sort order")
1706
1860
  .option("--json", "JSON output")
1707
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
+ }
1708
1865
  process.exitCode = await cmdList(options);
1709
1866
  });
1710
1867
 
package/src/lib/config.js CHANGED
@@ -68,6 +68,8 @@ export function buildInitialRepoConfig(profile = loadDefaultProfile()) {
68
68
  defaults: {
69
69
  planning: {
70
70
  node_type: profile.defaults?.planning?.node_type ?? "work",
71
+ lane: profile.defaults?.planning?.lane ?? null,
72
+ horizon: profile.defaults?.planning?.horizon ?? null,
71
73
  },
72
74
  claims: {
73
75
  ttl_minutes: profile.defaults?.claims?.ttl_minutes ?? DEFAULT_CLAIM_TTL_MINUTES,
@@ -133,6 +135,18 @@ export function validateRepoConfig(root = repoRoot()) {
133
135
  });
134
136
  }
135
137
 
138
+ for (const key of ["lane", "horizon"]) {
139
+ const value = config.defaults?.planning?.[key];
140
+ if (value !== undefined && value !== null && typeof value !== "string") {
141
+ issues.push({
142
+ severity: "error",
143
+ code: "CONFIG_DEFAULT_PLANNING_SCALAR_INVALID",
144
+ message: `defaults.planning.${key} must be a string or null`,
145
+ config_path: configPath,
146
+ });
147
+ }
148
+ }
149
+
136
150
  const ttlMinutes = config.defaults?.claims?.ttl_minutes;
137
151
  if (ttlMinutes !== undefined && (!Number.isInteger(ttlMinutes) || ttlMinutes <= 0)) {
138
152
  issues.push({
@@ -1,6 +1,6 @@
1
1
  export const BASE_DIR = ".tickets/spec";
2
2
  export const FORMAT_VERSION = 3;
3
- export const FORMAT_VERSION_URL = "version/20260317-tickets-spec.md";
3
+ export const FORMAT_VERSION_URL = "version/20260317-2-tickets-spec.md";
4
4
 
5
5
  export const STATUS_VALUES = ["todo", "doing", "blocked", "done", "canceled"];
6
6
  export const PRIORITY_VALUES = ["low", "medium", "high", "critical"];
@@ -11,3 +11,6 @@ export const CLAIM_ACTION_VALUES = ["acquire", "renew", "release", "override"];
11
11
  export const WORKFLOW_MODE_VALUES = ["auto", "doc_first", "skill_first"];
12
12
  export const GRAPH_VIEW_VALUES = ["dependency", "sequence", "portfolio", "all"];
13
13
  export const DEFAULT_CLAIM_TTL_MINUTES = 60;
14
+ export const LIST_SORT_VALUES = ["ready", "priority", "lane", "rank", "updated", "title"];
15
+ export const PLANNING_INDEX_FORMAT_ID = "0195a1b7-4a17-7c2e-8db2-4d5cb0f0d642";
16
+ export const PLANNING_INDEX_FORMAT_LABEL = "planning-index-2026-03-17";
@@ -0,0 +1,241 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ FORMAT_VERSION,
6
+ FORMAT_VERSION_URL,
7
+ PLANNING_INDEX_FORMAT_ID,
8
+ PLANNING_INDEX_FORMAT_LABEL,
9
+ } from "./constants.js";
10
+ import { loadClaimEvents, deriveActiveClaim } from "./claims.js";
11
+ import { loadWorkflowProfile, repoConfigPath } from "./config.js";
12
+ import { buildPlanningSnapshotFromRows, normalizePlanning } from "./planning.js";
13
+ import { collectTicketPaths } from "./validation.js";
14
+ import { ensureDir, loadTicket, readJsonl, repoRoot, ticketsDir } from "./util.js";
15
+
16
+ function fileSignature(filePath) {
17
+ if (!fs.existsSync(filePath)) {
18
+ return null;
19
+ }
20
+ const stat = fs.statSync(filePath);
21
+ return `${Math.trunc(stat.mtimeMs)}:${stat.size}`;
22
+ }
23
+
24
+ function listLogFiles(logsDir) {
25
+ if (!fs.existsSync(logsDir)) {
26
+ return [];
27
+ }
28
+
29
+ return fs
30
+ .readdirSync(logsDir, { withFileTypes: true })
31
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
32
+ .map((entry) => entry.name)
33
+ .sort((a, b) => a.localeCompare(b));
34
+ }
35
+
36
+ function computeLastUpdated(logsDir) {
37
+ let latest = "";
38
+
39
+ for (const name of listLogFiles(logsDir)) {
40
+ const logPath = path.join(logsDir, name);
41
+ for (const entry of readJsonl(logPath)) {
42
+ const ts = entry.ts;
43
+ if (typeof ts === "string" && (latest === "" || ts > latest)) {
44
+ latest = ts;
45
+ }
46
+ }
47
+ }
48
+
49
+ return latest;
50
+ }
51
+
52
+ function ticketPathsForRoot(root = repoRoot()) {
53
+ const previous = process.cwd();
54
+ try {
55
+ process.chdir(root);
56
+ return collectTicketPaths(null);
57
+ } finally {
58
+ process.chdir(previous);
59
+ }
60
+ }
61
+
62
+ function collectSourceState(root = repoRoot()) {
63
+ const configPath = repoConfigPath(root);
64
+ const ticketStates = ticketPathsForRoot(root)
65
+ .map((ticketPath) => {
66
+ const ticketDir = path.dirname(ticketPath);
67
+ const logsDir = path.join(ticketDir, "logs");
68
+ const logFiles = listLogFiles(logsDir);
69
+ return {
70
+ id: path.basename(ticketDir),
71
+ ticket_path: path.relative(root, ticketPath),
72
+ ticket_signature: fileSignature(ticketPath),
73
+ log_count: logFiles.length,
74
+ logs: logFiles.map((name) => ({
75
+ name,
76
+ signature: fileSignature(path.join(logsDir, name)),
77
+ })),
78
+ };
79
+ })
80
+ .sort((a, b) => a.id.localeCompare(b.id));
81
+
82
+ return {
83
+ config_fingerprint: fileSignature(configPath),
84
+ ticket_count: ticketStates.length,
85
+ tickets: ticketStates,
86
+ };
87
+ }
88
+
89
+ function buildRows(root = repoRoot(), profile = loadWorkflowProfile(root)) {
90
+ const rows = [];
91
+
92
+ for (const ticketPath of ticketPathsForRoot(root)) {
93
+ try {
94
+ const [frontMatter, body] = loadTicket(ticketPath);
95
+ const ticketDir = path.dirname(ticketPath);
96
+ const logsDir = path.join(ticketDir, "logs");
97
+ const activeClaim = deriveActiveClaim(loadClaimEvents(logsDir));
98
+
99
+ rows.push({
100
+ id: frontMatter.id ?? "",
101
+ title: frontMatter.title ?? "",
102
+ status: frontMatter.status ?? "",
103
+ priority: frontMatter.priority ?? "",
104
+ owner: frontMatter.assignment?.owner ?? null,
105
+ mode: frontMatter.assignment?.mode ?? null,
106
+ labels: Array.isArray(frontMatter.labels)
107
+ ? frontMatter.labels.filter((label) => typeof label === "string")
108
+ : [],
109
+ body: body ?? "",
110
+ path: ticketPath,
111
+ dependencies: Array.isArray(frontMatter.dependencies) ? frontMatter.dependencies : [],
112
+ blocks: Array.isArray(frontMatter.blocks) ? frontMatter.blocks : [],
113
+ related: Array.isArray(frontMatter.related) ? frontMatter.related : [],
114
+ planning: normalizePlanning(frontMatter, profile),
115
+ resolution: frontMatter.resolution ?? null,
116
+ active_claim: activeClaim,
117
+ last_updated: computeLastUpdated(logsDir),
118
+ });
119
+ } catch {
120
+ // Invalid tickets are surfaced by `validate`; derived views skip them.
121
+ }
122
+ }
123
+
124
+ return rows;
125
+ }
126
+
127
+ function indexPath(root = repoRoot()) {
128
+ return path.join(root, ".tickets", "derived", "planning-index.json");
129
+ }
130
+
131
+ function serializeMap(map) {
132
+ return Object.fromEntries([...map.entries()].map(([key, value]) => [key, [...value]]));
133
+ }
134
+
135
+ function buildIndexDocument(root = repoRoot()) {
136
+ const profile = loadWorkflowProfile(root);
137
+ const rows = buildRows(root, profile);
138
+ const snapshot = buildPlanningSnapshotFromRows(rows, profile);
139
+
140
+ return {
141
+ index_format_id: PLANNING_INDEX_FORMAT_ID,
142
+ index_format_label: PLANNING_INDEX_FORMAT_LABEL,
143
+ tool: {
144
+ format_version: FORMAT_VERSION,
145
+ format_version_url: FORMAT_VERSION_URL,
146
+ },
147
+ source_state: collectSourceState(root),
148
+ profile,
149
+ rows: snapshot.rows,
150
+ predecessors_by_id: serializeMap(snapshot.predecessorsById),
151
+ members_by_group: serializeMap(snapshot.membersByGroup),
152
+ };
153
+ }
154
+
155
+ function readIndex(root = repoRoot()) {
156
+ const outPath = indexPath(root);
157
+ if (!fs.existsSync(outPath)) {
158
+ return null;
159
+ }
160
+
161
+ try {
162
+ return JSON.parse(fs.readFileSync(outPath, "utf8"));
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ function isFresh(index, root = repoRoot()) {
169
+ if (!index) {
170
+ return false;
171
+ }
172
+ if (index.index_format_id !== PLANNING_INDEX_FORMAT_ID) {
173
+ return false;
174
+ }
175
+ if (index.tool?.format_version !== FORMAT_VERSION) {
176
+ return false;
177
+ }
178
+ if (index.tool?.format_version_url !== FORMAT_VERSION_URL) {
179
+ return false;
180
+ }
181
+
182
+ const currentState = collectSourceState(root);
183
+ return JSON.stringify(index.source_state ?? null) === JSON.stringify(currentState);
184
+ }
185
+
186
+ export function planningIndexPath(root = repoRoot()) {
187
+ return indexPath(root);
188
+ }
189
+
190
+ export function invalidatePlanningIndex(root = repoRoot()) {
191
+ const outPath = indexPath(root);
192
+ if (fs.existsSync(outPath)) {
193
+ fs.unlinkSync(outPath);
194
+ }
195
+ }
196
+
197
+ export function rebuildPlanningIndex(root = repoRoot()) {
198
+ const outPath = indexPath(root);
199
+ ensureDir(path.dirname(outPath));
200
+ const index = buildIndexDocument(root);
201
+ fs.writeFileSync(outPath, `${JSON.stringify(index, null, 2)}\n`);
202
+ return buildPlanningSnapshotFromRows(index.rows, index.profile);
203
+ }
204
+
205
+ export function refreshPlanningIndexIfPresent(root = repoRoot()) {
206
+ const outPath = indexPath(root);
207
+ if (!fs.existsSync(outPath)) {
208
+ return null;
209
+ }
210
+
211
+ try {
212
+ return rebuildPlanningIndex(root);
213
+ } catch {
214
+ invalidatePlanningIndex(root);
215
+ return null;
216
+ }
217
+ }
218
+
219
+ export function loadPlanningSnapshot(options = {}) {
220
+ const root = options.root ?? repoRoot();
221
+ const persist = options.persist ?? true;
222
+ const existing = readIndex(root);
223
+ if (existing && isFresh(existing, root)) {
224
+ return buildPlanningSnapshotFromRows(existing.rows ?? [], existing.profile ?? loadWorkflowProfile(root));
225
+ }
226
+
227
+ if (!persist) {
228
+ const profile = loadWorkflowProfile(root);
229
+ return buildPlanningSnapshotFromRows(buildRows(root, profile), profile);
230
+ }
231
+
232
+ return rebuildPlanningIndex(root);
233
+ }
234
+
235
+ export function currentSourceState(root = repoRoot()) {
236
+ return collectSourceState(root);
237
+ }
238
+
239
+ export function planningIndexExists(root = repoRoot()) {
240
+ return fs.existsSync(indexPath(root));
241
+ }