@picoai/tickets 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.tickets/spec/AGENTS_EXAMPLE.md +7 -4
- package/.tickets/spec/TICKETS.md +302 -383
- package/.tickets/spec/profile/defaults.yml +31 -0
- package/.tickets/spec/version/20260317-2-tickets-spec.md +106 -0
- package/.tickets/spec/version/20260317-tickets-spec.md +82 -0
- package/README.md +140 -184
- package/package.json +2 -1
- package/release-history.json +14 -0
- package/src/cli.js +479 -115
- package/src/lib/claims.js +66 -0
- package/src/lib/config.js +176 -0
- package/src/lib/constants.js +11 -2
- package/src/lib/index.js +241 -0
- package/src/lib/listing.js +24 -84
- package/src/lib/planning.js +452 -0
- package/src/lib/projections.js +79 -0
- package/src/lib/repair.js +1 -1
- package/src/lib/validation.js +380 -2
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
const TERMINAL_STATUSES = new Set(["done", "canceled"]);
|
|
2
|
+
const GROUP_NODE_TYPES = new Set(["group", "checkpoint"]);
|
|
3
|
+
const PRIORITY_ORDER = new Map([
|
|
4
|
+
["critical", 0],
|
|
5
|
+
["high", 1],
|
|
6
|
+
["medium", 2],
|
|
7
|
+
["low", 3],
|
|
8
|
+
["", 4],
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export function isTerminalStatus(status) {
|
|
12
|
+
return TERMINAL_STATUSES.has(status);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function normalizePlanning(frontMatter, profile) {
|
|
16
|
+
const planning = frontMatter.planning ?? {};
|
|
17
|
+
return {
|
|
18
|
+
node_type: planning.node_type ?? profile.defaults?.planning?.node_type ?? "work",
|
|
19
|
+
group_ids: Array.isArray(planning.group_ids) ? planning.group_ids : [],
|
|
20
|
+
lane: typeof planning.lane === "string" && planning.lane.trim() ? planning.lane.trim() : null,
|
|
21
|
+
rank: Number.isInteger(planning.rank) ? planning.rank : null,
|
|
22
|
+
horizon: typeof planning.horizon === "string" && planning.horizon.trim() ? planning.horizon.trim() : null,
|
|
23
|
+
precedes: Array.isArray(planning.precedes) ? planning.precedes : [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function cloneRow(row) {
|
|
28
|
+
return {
|
|
29
|
+
...row,
|
|
30
|
+
labels: Array.isArray(row.labels) ? [...row.labels] : [],
|
|
31
|
+
dependencies: Array.isArray(row.dependencies) ? [...row.dependencies] : [],
|
|
32
|
+
blocks: Array.isArray(row.blocks) ? [...row.blocks] : [],
|
|
33
|
+
related: Array.isArray(row.related) ? [...row.related] : [],
|
|
34
|
+
planning: {
|
|
35
|
+
node_type: row.planning?.node_type ?? "work",
|
|
36
|
+
group_ids: Array.isArray(row.planning?.group_ids) ? [...row.planning.group_ids] : [],
|
|
37
|
+
lane: row.planning?.lane ?? null,
|
|
38
|
+
rank: row.planning?.rank ?? null,
|
|
39
|
+
horizon: row.planning?.horizon ?? null,
|
|
40
|
+
precedes: Array.isArray(row.planning?.precedes) ? [...row.planning.precedes] : [],
|
|
41
|
+
},
|
|
42
|
+
active_claim: row.active_claim ? { ...row.active_claim } : null,
|
|
43
|
+
blocked_by: {
|
|
44
|
+
dependencies: Array.isArray(row.blocked_by?.dependencies) ? [...row.blocked_by.dependencies] : [],
|
|
45
|
+
predecessors: Array.isArray(row.blocked_by?.predecessors) ? [...row.blocked_by.predecessors] : [],
|
|
46
|
+
},
|
|
47
|
+
rollup: row.rollup ? { ...row.rollup } : null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function collectGroupLeaves(groupId, membersByGroup, nodesById, seen = new Set(), leaves = new Set()) {
|
|
52
|
+
if (seen.has(groupId)) {
|
|
53
|
+
return leaves;
|
|
54
|
+
}
|
|
55
|
+
seen.add(groupId);
|
|
56
|
+
|
|
57
|
+
for (const childId of membersByGroup.get(groupId) ?? []) {
|
|
58
|
+
const child = nodesById.get(childId);
|
|
59
|
+
if (!child) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (child.planning.node_type === "work") {
|
|
63
|
+
leaves.add(child.id);
|
|
64
|
+
} else {
|
|
65
|
+
collectGroupLeaves(child.id, membersByGroup, nodesById, seen, leaves);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return leaves;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function computeRollup(row, membersByGroup, nodesById) {
|
|
73
|
+
if (!GROUP_NODE_TYPES.has(row.planning.node_type)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const leafIds = [...collectGroupLeaves(row.id, membersByGroup, nodesById)];
|
|
78
|
+
const leafRows = leafIds.map((id) => nodesById.get(id)).filter(Boolean);
|
|
79
|
+
const merged = leafRows.filter((leaf) => leaf.resolution === "merged").length;
|
|
80
|
+
const dropped = leafRows.filter((leaf) => leaf.resolution === "dropped").length;
|
|
81
|
+
const activeLeafRows = leafRows.filter((leaf) => !["merged", "dropped"].includes(leaf.resolution ?? ""));
|
|
82
|
+
const doneCompleted = activeLeafRows.filter(
|
|
83
|
+
(leaf) => leaf.resolution === "completed" || (leaf.status === "done" && !leaf.resolution),
|
|
84
|
+
).length;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
total_leaf: leafRows.length,
|
|
88
|
+
active_leaf: activeLeafRows.length,
|
|
89
|
+
todo: activeLeafRows.filter((leaf) => leaf.status === "todo").length,
|
|
90
|
+
doing: activeLeafRows.filter((leaf) => leaf.status === "doing").length,
|
|
91
|
+
blocked: activeLeafRows.filter((leaf) => leaf.status === "blocked").length,
|
|
92
|
+
done_completed: doneCompleted,
|
|
93
|
+
merged,
|
|
94
|
+
dropped,
|
|
95
|
+
percent_complete: activeLeafRows.length === 0 ? 0 : doneCompleted / activeLeafRows.length,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function compareNullableStrings(a, b) {
|
|
100
|
+
return String(a ?? "").localeCompare(String(b ?? ""));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function compareNullableNumbers(a, b) {
|
|
104
|
+
const left = Number.isInteger(a) ? a : Number.MAX_SAFE_INTEGER;
|
|
105
|
+
const right = Number.isInteger(b) ? b : Number.MAX_SAFE_INTEGER;
|
|
106
|
+
return left - right;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function comparePriority(a, b) {
|
|
110
|
+
const left = PRIORITY_ORDER.get(a.priority ?? "") ?? PRIORITY_ORDER.get("");
|
|
111
|
+
const right = PRIORITY_ORDER.get(b.priority ?? "") ?? PRIORITY_ORDER.get("");
|
|
112
|
+
return left - right;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function compareLastUpdated(a, b) {
|
|
116
|
+
return String(b.last_updated ?? "").localeCompare(String(a.last_updated ?? ""));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function applyComparatorChain(a, b, comparators) {
|
|
120
|
+
for (const comparator of comparators) {
|
|
121
|
+
const result = comparator(a, b);
|
|
122
|
+
if (result !== 0) {
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function baseOperationalComparators() {
|
|
130
|
+
return [
|
|
131
|
+
(a, b) => compareNullableStrings(a.planning.lane, b.planning.lane),
|
|
132
|
+
(a, b) => compareNullableNumbers(a.planning.rank, b.planning.rank),
|
|
133
|
+
(a, b) => compareNullableStrings(a.title, b.title),
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function formatClaimSummary(activeClaim) {
|
|
138
|
+
if (!activeClaim?.holder_id) {
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
const until = activeClaim.expires_at ? ` until ${activeClaim.expires_at}` : "";
|
|
142
|
+
return `${activeClaim.holder_id}${until}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function buildPlanningSnapshotFromRows(rows, profile) {
|
|
146
|
+
const snapshotRows = rows.map((row) => cloneRow(row));
|
|
147
|
+
const nodesById = new Map(snapshotRows.map((row) => [row.id, row]));
|
|
148
|
+
const predecessorsById = new Map();
|
|
149
|
+
const membersByGroup = new Map();
|
|
150
|
+
|
|
151
|
+
for (const row of snapshotRows) {
|
|
152
|
+
for (const successorId of row.planning.precedes) {
|
|
153
|
+
if (!predecessorsById.has(successorId)) {
|
|
154
|
+
predecessorsById.set(successorId, []);
|
|
155
|
+
}
|
|
156
|
+
predecessorsById.get(successorId).push(row.id);
|
|
157
|
+
}
|
|
158
|
+
for (const groupId of row.planning.group_ids) {
|
|
159
|
+
if (!membersByGroup.has(groupId)) {
|
|
160
|
+
membersByGroup.set(groupId, []);
|
|
161
|
+
}
|
|
162
|
+
membersByGroup.get(groupId).push(row.id);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const row of snapshotRows) {
|
|
167
|
+
const unresolvedDependencies = row.dependencies.filter((id) => {
|
|
168
|
+
const dependency = nodesById.get(id);
|
|
169
|
+
return dependency ? !isTerminalStatus(dependency.status) : true;
|
|
170
|
+
});
|
|
171
|
+
const unresolvedPredecessors = (predecessorsById.get(row.id) ?? []).filter((id) => {
|
|
172
|
+
const predecessor = nodesById.get(id);
|
|
173
|
+
return predecessor ? !isTerminalStatus(predecessor.status) : true;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
row.blocked_by = {
|
|
177
|
+
dependencies: unresolvedDependencies,
|
|
178
|
+
predecessors: unresolvedPredecessors,
|
|
179
|
+
};
|
|
180
|
+
row.ready =
|
|
181
|
+
row.planning.node_type === "work" &&
|
|
182
|
+
!isTerminalStatus(row.status) &&
|
|
183
|
+
row.mode !== "human_only" &&
|
|
184
|
+
unresolvedDependencies.length === 0 &&
|
|
185
|
+
unresolvedPredecessors.length === 0;
|
|
186
|
+
row.claim_summary = formatClaimSummary(row.active_claim);
|
|
187
|
+
row.rollup = computeRollup(row, membersByGroup, nodesById);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
profile,
|
|
192
|
+
rows: snapshotRows,
|
|
193
|
+
nodesById,
|
|
194
|
+
predecessorsById,
|
|
195
|
+
membersByGroup,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function passesFilters(row, filters) {
|
|
200
|
+
if (filters.status && row.status !== filters.status) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
if (filters.priority && row.priority !== filters.priority) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
if (filters.mode && row.mode !== filters.mode) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
if (filters.owner && row.owner !== filters.owner) {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
if (filters.label && !row.labels.includes(filters.label)) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
if (filters.text) {
|
|
216
|
+
const needle = String(filters.text).toLowerCase();
|
|
217
|
+
const haystack = `${row.title}\n${row.body}`.toLowerCase();
|
|
218
|
+
if (!haystack.includes(needle)) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (filters.nodeType && row.planning.node_type !== filters.nodeType) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
if (filters.group && !row.planning.group_ids.includes(filters.group)) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
if (filters.lane && row.planning.lane !== filters.lane) {
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
if (filters.horizon && row.planning.horizon !== filters.horizon) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
if (filters.claimed && !row.active_claim) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
if (filters.claimedBy && row.active_claim?.holder_id !== filters.claimedBy) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
if (filters.ready && !row.ready) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function listPlanningRows(snapshot, filters = {}) {
|
|
247
|
+
return snapshot.rows.filter((row) => passesFilters(row, filters));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function sortPlanningRows(rows, sortBy = null, reverse = false) {
|
|
251
|
+
const comparatorsBySort = {
|
|
252
|
+
default: [
|
|
253
|
+
(a, b) => Number(b.ready) - Number(a.ready),
|
|
254
|
+
comparePriority,
|
|
255
|
+
...baseOperationalComparators(),
|
|
256
|
+
compareLastUpdated,
|
|
257
|
+
(a, b) => compareNullableStrings(a.title, b.title),
|
|
258
|
+
],
|
|
259
|
+
ready: [
|
|
260
|
+
(a, b) => Number(b.ready) - Number(a.ready),
|
|
261
|
+
...baseOperationalComparators(),
|
|
262
|
+
comparePriority,
|
|
263
|
+
compareLastUpdated,
|
|
264
|
+
],
|
|
265
|
+
priority: [comparePriority, ...baseOperationalComparators(), compareLastUpdated],
|
|
266
|
+
lane: [...baseOperationalComparators(), comparePriority, compareLastUpdated],
|
|
267
|
+
rank: [
|
|
268
|
+
(a, b) => compareNullableNumbers(a.planning.rank, b.planning.rank),
|
|
269
|
+
(a, b) => compareNullableStrings(a.planning.lane, b.planning.lane),
|
|
270
|
+
comparePriority,
|
|
271
|
+
compareLastUpdated,
|
|
272
|
+
(a, b) => compareNullableStrings(a.title, b.title),
|
|
273
|
+
],
|
|
274
|
+
updated: [compareLastUpdated, ...baseOperationalComparators(), comparePriority],
|
|
275
|
+
title: [(a, b) => compareNullableStrings(a.title, b.title), comparePriority, ...baseOperationalComparators()],
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const key = sortBy ?? "default";
|
|
279
|
+
const comparators = comparatorsBySort[key] ?? comparatorsBySort.default;
|
|
280
|
+
const sorted = [...rows].sort((a, b) => applyComparatorChain(a, b, comparators));
|
|
281
|
+
return reverse ? sorted.reverse() : sorted;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function buildGraphData(snapshot, options = {}) {
|
|
285
|
+
const view = options.view ?? "dependency";
|
|
286
|
+
const includeRelated = options.includeRelated ?? false;
|
|
287
|
+
const rootId = options.ticket ?? null;
|
|
288
|
+
const edgeMap = new Map();
|
|
289
|
+
const nodes = new Map(snapshot.rows.map((row) => [row.id, row]));
|
|
290
|
+
|
|
291
|
+
function addEdge(type, from, to) {
|
|
292
|
+
const key = `${type}:${from}:${to}`;
|
|
293
|
+
if (!edgeMap.has(key)) {
|
|
294
|
+
edgeMap.set(key, { type, from, to });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for (const row of snapshot.rows) {
|
|
299
|
+
if (["dependency", "all"].includes(view)) {
|
|
300
|
+
for (const dependency of row.dependencies) {
|
|
301
|
+
addEdge("dependency", dependency, row.id);
|
|
302
|
+
}
|
|
303
|
+
for (const blocked of row.blocks) {
|
|
304
|
+
addEdge("blocks", row.id, blocked);
|
|
305
|
+
}
|
|
306
|
+
if (includeRelated) {
|
|
307
|
+
for (const related of row.related) {
|
|
308
|
+
addEdge("related", row.id, related);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (["sequence", "portfolio", "all"].includes(view)) {
|
|
314
|
+
for (const successor of row.planning.precedes) {
|
|
315
|
+
addEdge("precedes", row.id, successor);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (["portfolio", "all"].includes(view)) {
|
|
320
|
+
for (const groupId of row.planning.group_ids) {
|
|
321
|
+
addEdge("contains", groupId, row.id);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let nodeIds = new Set(nodes.keys());
|
|
327
|
+
if (rootId) {
|
|
328
|
+
const queue = [rootId];
|
|
329
|
+
const seen = new Set();
|
|
330
|
+
while (queue.length > 0) {
|
|
331
|
+
const current = queue.shift();
|
|
332
|
+
if (!current || seen.has(current)) {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
seen.add(current);
|
|
336
|
+
for (const edge of edgeMap.values()) {
|
|
337
|
+
if (edge.from === current && !seen.has(edge.to)) {
|
|
338
|
+
queue.push(edge.to);
|
|
339
|
+
}
|
|
340
|
+
if (edge.to === current && !seen.has(edge.from)) {
|
|
341
|
+
queue.push(edge.from);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
nodeIds = seen;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const filteredEdges = [...edgeMap.values()].filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to));
|
|
349
|
+
const filteredNodes = [...nodeIds].map((id) => nodes.get(id)).filter(Boolean);
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
root_id: rootId,
|
|
353
|
+
nodes: filteredNodes,
|
|
354
|
+
edges: filteredEdges,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function collectScopedIds(groupId, membersByGroup) {
|
|
359
|
+
const seen = new Set();
|
|
360
|
+
const queue = [groupId];
|
|
361
|
+
|
|
362
|
+
while (queue.length > 0) {
|
|
363
|
+
const current = queue.shift();
|
|
364
|
+
if (!current || seen.has(current)) {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
seen.add(current);
|
|
368
|
+
for (const childId of membersByGroup.get(current) ?? []) {
|
|
369
|
+
queue.push(childId);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return seen;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function buildPlanRow(row) {
|
|
377
|
+
return {
|
|
378
|
+
id: row.id,
|
|
379
|
+
title: row.title,
|
|
380
|
+
status: row.status,
|
|
381
|
+
priority: row.priority,
|
|
382
|
+
node_type: row.planning.node_type,
|
|
383
|
+
lane: row.planning.lane,
|
|
384
|
+
rank: row.planning.rank,
|
|
385
|
+
horizon: row.planning.horizon,
|
|
386
|
+
group_ids: [...row.planning.group_ids],
|
|
387
|
+
active_claim: row.active_claim,
|
|
388
|
+
claim_summary: row.claim_summary,
|
|
389
|
+
owner: row.owner,
|
|
390
|
+
mode: row.mode,
|
|
391
|
+
blocked_by: {
|
|
392
|
+
dependencies: [...row.blocked_by.dependencies],
|
|
393
|
+
predecessors: [...row.blocked_by.predecessors],
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function buildPlanSummary(snapshot, options = {}) {
|
|
399
|
+
let rows = snapshot.rows;
|
|
400
|
+
|
|
401
|
+
if (options.group) {
|
|
402
|
+
const scopedIds = collectScopedIds(options.group, snapshot.membersByGroup);
|
|
403
|
+
rows = rows.filter((row) => scopedIds.has(row.id));
|
|
404
|
+
}
|
|
405
|
+
if (options.horizon) {
|
|
406
|
+
rows = rows.filter((row) => row.planning.horizon === options.horizon);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const workRows = rows.filter((row) => row.planning.node_type === "work" && !isTerminalStatus(row.status));
|
|
410
|
+
const ready = sortPlanningRows(
|
|
411
|
+
workRows.filter((row) => row.ready && row.status === "todo"),
|
|
412
|
+
"lane",
|
|
413
|
+
false,
|
|
414
|
+
).map(buildPlanRow);
|
|
415
|
+
const active = sortPlanningRows(
|
|
416
|
+
workRows.filter((row) => row.status === "doing"),
|
|
417
|
+
"lane",
|
|
418
|
+
false,
|
|
419
|
+
).map(buildPlanRow);
|
|
420
|
+
const blocked = sortPlanningRows(
|
|
421
|
+
workRows.filter(
|
|
422
|
+
(row) =>
|
|
423
|
+
!row.ready &&
|
|
424
|
+
(row.status === "blocked" || row.blocked_by.dependencies.length > 0 || row.blocked_by.predecessors.length > 0),
|
|
425
|
+
),
|
|
426
|
+
"lane",
|
|
427
|
+
false,
|
|
428
|
+
).map(buildPlanRow);
|
|
429
|
+
const groups = sortPlanningRows(
|
|
430
|
+
rows.filter((row) => GROUP_NODE_TYPES.has(row.planning.node_type)),
|
|
431
|
+
"lane",
|
|
432
|
+
false,
|
|
433
|
+
).map((row) => ({
|
|
434
|
+
id: row.id,
|
|
435
|
+
title: row.title,
|
|
436
|
+
node_type: row.planning.node_type,
|
|
437
|
+
lane: row.planning.lane,
|
|
438
|
+
rank: row.planning.rank,
|
|
439
|
+
horizon: row.planning.horizon,
|
|
440
|
+
rollup: row.rollup,
|
|
441
|
+
}));
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
generated_at: new Date().toISOString(),
|
|
445
|
+
workflow_mode: snapshot.profile.workflow?.mode ?? "auto",
|
|
446
|
+
semantics: snapshot.profile.semantics?.terms ?? {},
|
|
447
|
+
ready,
|
|
448
|
+
active,
|
|
449
|
+
blocked,
|
|
450
|
+
groups,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { buildInitialRepoConfig, loadDefaultProfile, renderRepoConfig, repoConfigPath } from "./config.js";
|
|
5
|
+
import { ensureDir, repoRoot } from "./util.js";
|
|
6
|
+
|
|
7
|
+
function renderSemanticTerms(profile) {
|
|
8
|
+
const terms = profile.semantics?.terms ?? {};
|
|
9
|
+
return Object.entries(terms)
|
|
10
|
+
.map(([name, mapping]) => {
|
|
11
|
+
const field = mapping.field ?? "custom";
|
|
12
|
+
const suffix = mapping.value ? ` = \`${mapping.value}\`` : "";
|
|
13
|
+
const description = mapping.description ? ` ${mapping.description}` : "";
|
|
14
|
+
return `- \`${name}\` -> \`${field}\`${suffix}.${description}`.replace(/\.\s+\./g, ".");
|
|
15
|
+
})
|
|
16
|
+
.join("\n");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function repoSkillPath(root = repoRoot()) {
|
|
20
|
+
return path.join(root, ".tickets", "skills", "tickets", "SKILL.md");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function renderRepoSkill(profile = loadDefaultProfile()) {
|
|
24
|
+
const config = buildInitialRepoConfig(profile);
|
|
25
|
+
return [
|
|
26
|
+
"# tickets",
|
|
27
|
+
"",
|
|
28
|
+
"This repo skill mirrors the canonical ticketing workflow in `TICKETS.md`.",
|
|
29
|
+
"Use it when your environment supports repo-local skills. In all cases, use `npx @picoai/tickets` as the only state-changing interface.",
|
|
30
|
+
"",
|
|
31
|
+
"## Required behavior",
|
|
32
|
+
"- Read `TICKETS.md` for the full repo contract when context is missing.",
|
|
33
|
+
"- Consult `.tickets/config.yml` for repo-local defaults and semantic overrides before interpreting planning terminology or creating new tickets.",
|
|
34
|
+
"- Validate assigned tickets before implementation with `npx @picoai/tickets validate`.",
|
|
35
|
+
"- Use `npx @picoai/tickets status`, `log`, `claim`, `plan`, and `graph` instead of editing derived state manually.",
|
|
36
|
+
"- When humans use terms like feature, phase, milestone, roadmap, or repo-specific equivalents, translate them through `.tickets/config.yml` and then call the generic CLI fields.",
|
|
37
|
+
"- Respect repo overrides in `.tickets/config.yml` and any narrative guidance in `TICKETS.override.md` if present.",
|
|
38
|
+
"",
|
|
39
|
+
"## Core planning model",
|
|
40
|
+
"- `planning.node_type`: `work`, `group`, or `checkpoint`.",
|
|
41
|
+
"- `planning.group_ids`: group membership edges.",
|
|
42
|
+
"- `planning.precedes`: sequencing edges, separate from hard `dependencies`.",
|
|
43
|
+
"- `planning.lane`, `planning.rank`, and `planning.horizon`: generic ordering and roadmap dimensions.",
|
|
44
|
+
"- `resolution`: terminal work outcome (`completed`, `merged`, `dropped`).",
|
|
45
|
+
"",
|
|
46
|
+
"## Default semantic mapping",
|
|
47
|
+
renderSemanticTerms(config),
|
|
48
|
+
"",
|
|
49
|
+
"Repo-specific semantic overrides live in `.tickets/config.yml`. Treat the list above as defaults only.",
|
|
50
|
+
"",
|
|
51
|
+
"## Claims",
|
|
52
|
+
"- Claims are optional advisory leases stored in ticket logs.",
|
|
53
|
+
"- Acquire or renew with `npx @picoai/tickets claim --ticket <id>`.",
|
|
54
|
+
"- Release with `npx @picoai/tickets claim --ticket <id> --release`.",
|
|
55
|
+
`- Default claim TTL is ${config.defaults?.claims?.ttl_minutes ?? 60} minutes unless the repo config overrides it.`,
|
|
56
|
+
"",
|
|
57
|
+
"## Planning views",
|
|
58
|
+
"- Use `npx @picoai/tickets list` for broad queue/reporting views.",
|
|
59
|
+
"- Use `npx @picoai/tickets plan` for operational state: ready, in-progress, blocked, and group rollups.",
|
|
60
|
+
"- Use `npx @picoai/tickets graph` for structural relationships, not execution state.",
|
|
61
|
+
"",
|
|
62
|
+
].join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function syncRepoConfig(root = repoRoot()) {
|
|
66
|
+
const configPath = repoConfigPath(root);
|
|
67
|
+
ensureDir(path.dirname(configPath));
|
|
68
|
+
if (!fs.existsSync(configPath)) {
|
|
69
|
+
fs.writeFileSync(configPath, renderRepoConfig(loadDefaultProfile()));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function syncRepoSkill(root = repoRoot(), apply = false) {
|
|
74
|
+
const skillPath = repoSkillPath(root);
|
|
75
|
+
ensureDir(path.dirname(skillPath));
|
|
76
|
+
if (apply || !fs.existsSync(skillPath)) {
|
|
77
|
+
fs.writeFileSync(skillPath, renderRepoSkill(loadDefaultProfile()));
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/lib/repair.js
CHANGED
|
@@ -175,7 +175,7 @@ function describeRepair(repair) {
|
|
|
175
175
|
return ["Set ticket id to a valid UUIDv7 (used to identify the ticket).", newUuidv7()];
|
|
176
176
|
}
|
|
177
177
|
if (field === "version") {
|
|
178
|
-
return [
|
|
178
|
+
return [`Set format version (integer, current ${FORMAT_VERSION}).`, FORMAT_VERSION];
|
|
179
179
|
}
|
|
180
180
|
if (field === "version_url") {
|
|
181
181
|
return ["Set version_url (path to the format definition for this version).", FORMAT_VERSION_URL];
|