@picoai/tickets 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tickets/spec/AGENTS_EXAMPLE.md +2 -0
- package/.tickets/spec/TICKETS.md +20 -11
- package/.tickets/spec/profile/defaults.yml +2 -0
- package/.tickets/spec/version/20260317-2-tickets-spec.md +106 -0
- package/README.md +23 -2
- package/package.json +1 -1
- package/release-history.json +7 -0
- package/src/cli.js +205 -48
- package/src/lib/config.js +14 -0
- package/src/lib/constants.js +4 -1
- package/src/lib/index.js +241 -0
- package/src/lib/listing.js +6 -3
- package/src/lib/planning.js +249 -152
- package/src/lib/projections.js +9 -0
- package/src/lib/validation.js +218 -0
package/src/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
|
|
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
|
|
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
|
|
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
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
-
|
|
1306
|
-
|
|
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 =
|
|
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({
|
package/src/lib/constants.js
CHANGED
|
@@ -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";
|
package/src/lib/index.js
ADDED
|
@@ -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
|
+
}
|