@picoai/tickets 0.3.0 → 0.5.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.
@@ -1,43 +1,18 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
-
4
- import { loadWorkflowProfile } from "./config.js";
5
- import { deriveActiveClaim, loadClaimEvents } from "./claims.js";
6
- import { collectTicketPaths } from "./validation.js";
7
- import { loadTicket, readJsonl } from "./util.js";
8
-
9
1
  const TERMINAL_STATUSES = new Set(["done", "canceled"]);
10
-
11
- function isTerminal(status) {
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
12
  return TERMINAL_STATUSES.has(status);
13
13
  }
14
14
 
15
- function lastUpdated(ticketDir) {
16
- const logsDir = path.join(ticketDir, "logs");
17
- let latest = "";
18
-
19
- if (!fs.existsSync(logsDir)) {
20
- return latest;
21
- }
22
-
23
- const logFiles = fs
24
- .readdirSync(logsDir, { withFileTypes: true })
25
- .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
26
- .map((entry) => path.join(logsDir, entry.name));
27
-
28
- for (const logFile of logFiles) {
29
- for (const entry of readJsonl(logFile)) {
30
- const ts = entry.ts;
31
- if (typeof ts === "string" && (latest === "" || ts > latest)) {
32
- latest = ts;
33
- }
34
- }
35
- }
36
-
37
- return latest;
38
- }
39
-
40
- function normalizePlanning(frontMatter, profile) {
15
+ export function normalizePlanning(frontMatter, profile) {
41
16
  const planning = frontMatter.planning ?? {};
42
17
  return {
43
18
  node_type: planning.node_type ?? profile.defaults?.planning?.node_type ?? "work",
@@ -49,6 +24,30 @@ function normalizePlanning(frontMatter, profile) {
49
24
  };
50
25
  }
51
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
+
52
51
  function collectGroupLeaves(groupId, membersByGroup, nodesById, seen = new Set(), leaves = new Set()) {
53
52
  if (seen.has(groupId)) {
54
53
  return leaves;
@@ -71,7 +70,7 @@ function collectGroupLeaves(groupId, membersByGroup, nodesById, seen = new Set()
71
70
  }
72
71
 
73
72
  function computeRollup(row, membersByGroup, nodesById) {
74
- if (!["group", "checkpoint"].includes(row.planning.node_type)) {
73
+ if (!GROUP_NODE_TYPES.has(row.planning.node_type)) {
75
74
  return null;
76
75
  }
77
76
 
@@ -97,6 +96,106 @@ function computeRollup(row, membersByGroup, nodesById) {
97
96
  };
98
97
  }
99
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
+
100
199
  function passesFilters(row, filters) {
101
200
  if (filters.status && row.status !== filters.status) {
102
201
  return false;
@@ -144,90 +243,42 @@ function passesFilters(row, filters) {
144
243
  return true;
145
244
  }
146
245
 
147
- export function buildPlanningSnapshot(options = {}) {
148
- const profile = options.profile ?? loadWorkflowProfile();
149
- const paths = collectTicketPaths(null);
150
- const rows = [];
151
-
152
- for (const ticketPath of paths) {
153
- try {
154
- const [frontMatter, body] = loadTicket(ticketPath);
155
- const ticketDir = path.dirname(ticketPath);
156
- const activeClaim = deriveActiveClaim(loadClaimEvents(path.join(ticketDir, "logs")));
157
-
158
- rows.push({
159
- id: frontMatter.id ?? "",
160
- title: frontMatter.title ?? "",
161
- status: frontMatter.status ?? "",
162
- priority: frontMatter.priority ?? "",
163
- owner: frontMatter.assignment?.owner ?? null,
164
- mode: frontMatter.assignment?.mode ?? null,
165
- labels: Array.isArray(frontMatter.labels) ? frontMatter.labels.filter((label) => typeof label === "string") : [],
166
- body: body ?? "",
167
- path: ticketPath,
168
- dependencies: Array.isArray(frontMatter.dependencies) ? frontMatter.dependencies : [],
169
- blocks: Array.isArray(frontMatter.blocks) ? frontMatter.blocks : [],
170
- related: Array.isArray(frontMatter.related) ? frontMatter.related : [],
171
- planning: normalizePlanning(frontMatter, profile),
172
- resolution: frontMatter.resolution ?? null,
173
- active_claim: activeClaim,
174
- last_updated: lastUpdated(ticketDir),
175
- });
176
- } catch {
177
- // Invalid tickets are surfaced by `validate`; views skip them.
178
- }
179
- }
180
-
181
- const nodesById = new Map(rows.map((row) => [row.id, row]));
182
- const predecessorsById = new Map();
183
- const membersByGroup = new Map();
184
-
185
- for (const row of rows) {
186
- for (const successorId of row.planning.precedes) {
187
- if (!predecessorsById.has(successorId)) {
188
- predecessorsById.set(successorId, []);
189
- }
190
- predecessorsById.get(successorId).push(row.id);
191
- }
192
- for (const groupId of row.planning.group_ids) {
193
- if (!membersByGroup.has(groupId)) {
194
- membersByGroup.set(groupId, []);
195
- }
196
- membersByGroup.get(groupId).push(row.id);
197
- }
198
- }
199
-
200
- for (const row of rows) {
201
- const depsSatisfied = row.dependencies.every((id) => {
202
- const dependency = nodesById.get(id);
203
- return dependency ? isTerminal(dependency.status) : false;
204
- });
205
- const predecessorsSatisfied = (predecessorsById.get(row.id) ?? []).every((id) => {
206
- const predecessor = nodesById.get(id);
207
- return predecessor ? isTerminal(predecessor.status) : false;
208
- });
209
-
210
- row.ready =
211
- row.planning.node_type === "work" &&
212
- !isTerminal(row.status) &&
213
- row.mode !== "human_only" &&
214
- depsSatisfied &&
215
- predecessorsSatisfied;
216
- row.rollup = computeRollup(row, membersByGroup, nodesById);
217
- }
246
+ export function listPlanningRows(snapshot, filters = {}) {
247
+ return snapshot.rows.filter((row) => passesFilters(row, filters));
248
+ }
218
249
 
219
- return {
220
- profile,
221
- rows,
222
- nodesById,
223
- predecessorsById,
224
- membersByGroup,
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()],
225
276
  };
226
- }
227
277
 
228
- export function listPlanningRows(filters = {}, options = {}) {
229
- const snapshot = buildPlanningSnapshot(options);
230
- return snapshot.rows.filter((row) => passesFilters(row, filters));
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;
231
282
  }
232
283
 
233
284
  export function buildGraphData(snapshot, options = {}) {
@@ -304,52 +355,98 @@ export function buildGraphData(snapshot, options = {}) {
304
355
  };
305
356
  }
306
357
 
307
- export function buildPlanSummary(options = {}) {
308
- const snapshot = buildPlanningSnapshot(options);
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 = {}) {
309
399
  let rows = snapshot.rows;
310
400
 
311
401
  if (options.group) {
312
- rows = rows.filter((row) => row.id === options.group || row.planning.group_ids.includes(options.group));
402
+ const scopedIds = collectScopedIds(options.group, snapshot.membersByGroup);
403
+ rows = rows.filter((row) => scopedIds.has(row.id));
313
404
  }
314
405
  if (options.horizon) {
315
406
  rows = rows.filter((row) => row.planning.horizon === options.horizon);
316
407
  }
317
408
 
318
- const groups = rows.filter((row) => ["group", "checkpoint"].includes(row.planning.node_type));
319
- const ready = rows
320
- .filter((row) => row.ready)
321
- .sort((a, b) => {
322
- const lane = String(a.planning.lane ?? "").localeCompare(String(b.planning.lane ?? ""));
323
- if (lane !== 0) {
324
- return lane;
325
- }
326
- const rankA = a.planning.rank ?? Number.MAX_SAFE_INTEGER;
327
- const rankB = b.planning.rank ?? Number.MAX_SAFE_INTEGER;
328
- if (rankA !== rankB) {
329
- return rankA - rankB;
330
- }
331
- return a.title.localeCompare(b.title);
332
- });
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
+ }));
333
442
 
334
443
  return {
335
444
  generated_at: new Date().toISOString(),
336
445
  workflow_mode: snapshot.profile.workflow?.mode ?? "auto",
337
446
  semantics: snapshot.profile.semantics?.terms ?? {},
338
- ready: ready.map((row) => ({
339
- id: row.id,
340
- title: row.title,
341
- lane: row.planning.lane,
342
- rank: row.planning.rank,
343
- horizon: row.planning.horizon,
344
- active_claim: row.active_claim,
345
- })),
346
- groups: groups.map((row) => ({
347
- id: row.id,
348
- title: row.title,
349
- node_type: row.planning.node_type,
350
- lane: row.planning.lane,
351
- horizon: row.planning.horizon,
352
- rollup: row.rollup,
353
- })),
447
+ ready,
448
+ active,
449
+ blocked,
450
+ groups,
354
451
  };
355
452
  }
@@ -30,8 +30,14 @@ export function renderRepoSkill(profile = loadDefaultProfile()) {
30
30
  "",
31
31
  "## Required behavior",
32
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.",
33
34
  "- Validate assigned tickets before implementation with `npx @picoai/tickets validate`.",
35
+ "- Before setting a ticket to `done`, confirm the ticket's `## Acceptance Criteria` are met and its `## Verification` checks passed.",
36
+ "- If those completion gates are not satisfied, stop and ask a human whether to keep working or explicitly override the gates. Only move the ticket to `done` after that human decision.",
37
+ "- Record `completion` metadata every time a ticket is moved to `done`.",
38
+ "- When a human overrides incomplete completion gates, record the exception through `npx @picoai/tickets status --status done --acceptance-criteria ... --verification-state ... --override-by ... --override-reason ...` so `ticket.md` and the status log both reflect it.",
34
39
  "- Use `npx @picoai/tickets status`, `log`, `claim`, `plan`, and `graph` instead of editing derived state manually.",
40
+ "- 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.",
35
41
  "- Respect repo overrides in `.tickets/config.yml` and any narrative guidance in `TICKETS.override.md` if present.",
36
42
  "",
37
43
  "## Core planning model",
@@ -44,12 +50,19 @@ export function renderRepoSkill(profile = loadDefaultProfile()) {
44
50
  "## Default semantic mapping",
45
51
  renderSemanticTerms(config),
46
52
  "",
53
+ "Repo-specific semantic overrides live in `.tickets/config.yml`. Treat the list above as defaults only.",
54
+ "",
47
55
  "## Claims",
48
56
  "- Claims are optional advisory leases stored in ticket logs.",
49
57
  "- Acquire or renew with `npx @picoai/tickets claim --ticket <id>`.",
50
58
  "- Release with `npx @picoai/tickets claim --ticket <id> --release`.",
51
59
  `- Default claim TTL is ${config.defaults?.claims?.ttl_minutes ?? 60} minutes unless the repo config overrides it.`,
52
60
  "",
61
+ "## Planning views",
62
+ "- Use `npx @picoai/tickets list` for broad queue/reporting views.",
63
+ "- Use `npx @picoai/tickets plan` for operational state: ready, in-progress, blocked, and group rollups.",
64
+ "- Use `npx @picoai/tickets graph` for structural relationships, not execution state.",
65
+ "",
53
66
  ].join("\n");
54
67
  }
55
68