@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.
@@ -0,0 +1,66 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { CLAIM_ACTION_VALUES } from "./constants.js";
5
+ import { readJsonl } from "./util.js";
6
+
7
+ function claimEventsForLog(logPath) {
8
+ return readJsonl(logPath)
9
+ .filter((entry) => entry.event_type === "claim" && entry.claim && typeof entry.claim === "object")
10
+ .map((entry) => ({ ...entry, log_path: logPath }));
11
+ }
12
+
13
+ export function loadClaimEvents(logsDir) {
14
+ if (!fs.existsSync(logsDir)) {
15
+ return [];
16
+ }
17
+
18
+ return fs
19
+ .readdirSync(logsDir, { withFileTypes: true })
20
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
21
+ .map((entry) => path.join(logsDir, entry.name))
22
+ .sort((a, b) => a.localeCompare(b))
23
+ .flatMap((logPath) => claimEventsForLog(logPath))
24
+ .sort((a, b) => `${a.ts ?? ""}:${a.run_started ?? ""}`.localeCompare(`${b.ts ?? ""}:${b.run_started ?? ""}`));
25
+ }
26
+
27
+ export function deriveActiveClaim(entries, now = new Date()) {
28
+ let active = null;
29
+
30
+ for (const entry of entries) {
31
+ const claim = entry.claim;
32
+ if (!claim || !CLAIM_ACTION_VALUES.includes(claim.action)) {
33
+ continue;
34
+ }
35
+
36
+ if (claim.action === "release") {
37
+ if (active && (!claim.claim_id || claim.claim_id === active.claim_id)) {
38
+ active = null;
39
+ }
40
+ continue;
41
+ }
42
+
43
+ active = {
44
+ claim_id: claim.claim_id,
45
+ action: claim.action,
46
+ holder_id: claim.holder_id,
47
+ holder_type: claim.holder_type,
48
+ reason: claim.reason ?? "",
49
+ ttl_minutes: claim.ttl_minutes,
50
+ expires_at: claim.expires_at,
51
+ supersedes_claim_id: claim.supersedes_claim_id ?? null,
52
+ ts: entry.ts,
53
+ };
54
+ }
55
+
56
+ if (!active) {
57
+ return null;
58
+ }
59
+
60
+ const expiresAt = active.expires_at ? Date.parse(active.expires_at) : Number.NaN;
61
+ if (!Number.isNaN(expiresAt) && expiresAt <= now.getTime()) {
62
+ return null;
63
+ }
64
+
65
+ return active;
66
+ }
@@ -0,0 +1,176 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import yaml from "yaml";
5
+
6
+ import {
7
+ DEFAULT_CLAIM_TTL_MINUTES,
8
+ PLANNING_NODE_TYPES,
9
+ WORKFLOW_MODE_VALUES,
10
+ } from "./constants.js";
11
+ import { packageRoot, repoRoot, ticketsDir } from "./util.js";
12
+
13
+ const DEFAULT_PROFILE_RELATIVE_PATH = path.join(".tickets", "spec", "profile", "defaults.yml");
14
+
15
+ function isObject(value) {
16
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
17
+ }
18
+
19
+ function deepMerge(base, override) {
20
+ if (!isObject(base) || !isObject(override)) {
21
+ return override;
22
+ }
23
+
24
+ const merged = { ...base };
25
+ for (const [key, value] of Object.entries(override)) {
26
+ if (isObject(value) && isObject(base[key])) {
27
+ merged[key] = deepMerge(base[key], value);
28
+ } else {
29
+ merged[key] = value;
30
+ }
31
+ }
32
+ return merged;
33
+ }
34
+
35
+ export function defaultProfilePath() {
36
+ return path.join(packageRoot(), DEFAULT_PROFILE_RELATIVE_PATH);
37
+ }
38
+
39
+ export function repoConfigPath(root = repoRoot()) {
40
+ return path.join(root, ".tickets", "config.yml");
41
+ }
42
+
43
+ export function loadDefaultProfile() {
44
+ return yaml.parse(fs.readFileSync(defaultProfilePath(), "utf8")) ?? {};
45
+ }
46
+
47
+ export function loadRepoConfig(root = repoRoot()) {
48
+ const configPath = repoConfigPath(root);
49
+ if (!fs.existsSync(configPath)) {
50
+ return {};
51
+ }
52
+ const parsed = yaml.parse(fs.readFileSync(configPath, "utf8")) ?? {};
53
+ if (!isObject(parsed)) {
54
+ throw new Error("Repo config must be a mapping");
55
+ }
56
+ return parsed;
57
+ }
58
+
59
+ export function loadWorkflowProfile(root = repoRoot()) {
60
+ return deepMerge(loadDefaultProfile(), loadRepoConfig(root));
61
+ }
62
+
63
+ export function buildInitialRepoConfig(profile = loadDefaultProfile()) {
64
+ return {
65
+ workflow: {
66
+ mode: profile.workflow?.mode ?? "auto",
67
+ },
68
+ defaults: {
69
+ planning: {
70
+ node_type: profile.defaults?.planning?.node_type ?? "work",
71
+ lane: profile.defaults?.planning?.lane ?? null,
72
+ horizon: profile.defaults?.planning?.horizon ?? null,
73
+ },
74
+ claims: {
75
+ ttl_minutes: profile.defaults?.claims?.ttl_minutes ?? DEFAULT_CLAIM_TTL_MINUTES,
76
+ },
77
+ },
78
+ semantics: {
79
+ terms: profile.semantics?.terms ?? {},
80
+ },
81
+ views: profile.views ?? {},
82
+ };
83
+ }
84
+
85
+ export function renderRepoConfig(profile = loadDefaultProfile()) {
86
+ const body = yaml.stringify(buildInitialRepoConfig(profile)).trimEnd();
87
+ return [
88
+ "# Repo-local @picoai/tickets overrides",
89
+ "# This file is authoritative for local semantic mapping and defaults.",
90
+ "# Safe to customize. `init --apply` will not overwrite it.",
91
+ "",
92
+ body,
93
+ "",
94
+ ].join("\n");
95
+ }
96
+
97
+ export function validateRepoConfig(root = repoRoot()) {
98
+ const issues = [];
99
+ const configPath = repoConfigPath(root);
100
+
101
+ if (!fs.existsSync(configPath)) {
102
+ return issues;
103
+ }
104
+
105
+ let config;
106
+ try {
107
+ config = loadRepoConfig(root);
108
+ } catch (error) {
109
+ issues.push({
110
+ severity: "error",
111
+ code: "CONFIG_INVALID",
112
+ message: String(error.message ?? error),
113
+ config_path: configPath,
114
+ });
115
+ return issues;
116
+ }
117
+
118
+ const mode = config.workflow?.mode;
119
+ if (mode !== undefined && !WORKFLOW_MODE_VALUES.includes(mode)) {
120
+ issues.push({
121
+ severity: "error",
122
+ code: "CONFIG_WORKFLOW_MODE_INVALID",
123
+ message: `workflow.mode must be one of ${WORKFLOW_MODE_VALUES.join(", ")}`,
124
+ config_path: configPath,
125
+ });
126
+ }
127
+
128
+ const nodeType = config.defaults?.planning?.node_type;
129
+ if (nodeType !== undefined && !PLANNING_NODE_TYPES.includes(nodeType)) {
130
+ issues.push({
131
+ severity: "error",
132
+ code: "CONFIG_DEFAULT_NODE_TYPE_INVALID",
133
+ message: `defaults.planning.node_type must be one of ${PLANNING_NODE_TYPES.join(", ")}`,
134
+ config_path: configPath,
135
+ });
136
+ }
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
+
150
+ const ttlMinutes = config.defaults?.claims?.ttl_minutes;
151
+ if (ttlMinutes !== undefined && (!Number.isInteger(ttlMinutes) || ttlMinutes <= 0)) {
152
+ issues.push({
153
+ severity: "error",
154
+ code: "CONFIG_CLAIM_TTL_INVALID",
155
+ message: "defaults.claims.ttl_minutes must be a positive integer",
156
+ config_path: configPath,
157
+ });
158
+ }
159
+
160
+ const terms = config.semantics?.terms;
161
+ if (terms !== undefined && !isObject(terms)) {
162
+ issues.push({
163
+ severity: "error",
164
+ code: "CONFIG_TERMS_INVALID",
165
+ message: "semantics.terms must be a mapping",
166
+ config_path: configPath,
167
+ });
168
+ }
169
+
170
+ return issues;
171
+ }
172
+
173
+ export function ensureTicketsRoot(root = repoRoot()) {
174
+ fs.mkdirSync(ticketsDir(), { recursive: true });
175
+ fs.mkdirSync(path.dirname(repoConfigPath(root)), { recursive: true });
176
+ }
@@ -1,7 +1,16 @@
1
1
  export const BASE_DIR = ".tickets/spec";
2
- export const FORMAT_VERSION = 2;
3
- export const FORMAT_VERSION_URL = "version/20260311-tickets-spec.md";
2
+ export const FORMAT_VERSION = 3;
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"];
7
7
  export const ASSIGNMENT_MODE_VALUES = ["human_only", "agent_only", "mixed"];
8
+ export const PLANNING_NODE_TYPES = ["work", "group", "checkpoint"];
9
+ export const RESOLUTION_VALUES = ["completed", "merged", "dropped"];
10
+ export const CLAIM_ACTION_VALUES = ["acquire", "renew", "release", "override"];
11
+ export const WORKFLOW_MODE_VALUES = ["auto", "doc_first", "skill_first"];
12
+ export const GRAPH_VIEW_VALUES = ["dependency", "sequence", "portfolio", "all"];
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
+ }
@@ -1,85 +1,25 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
-
4
- import { readJsonl } from "./util.js";
5
- import { collectTicketPaths, validateTicket } from "./validation.js";
6
-
7
- export function listTickets(filters) {
8
- const rows = [];
9
-
10
- for (const ticketPath of collectTicketPaths(null)) {
11
- const [, frontMatter, body] = validateTicket(ticketPath);
12
- if (!frontMatter || Object.keys(frontMatter).length === 0) {
13
- continue;
14
- }
15
- if (!passesFilters(frontMatter, body, filters)) {
16
- continue;
17
- }
18
-
19
- rows.push({
20
- id: frontMatter.id ?? "",
21
- title: frontMatter.title ?? "",
22
- status: frontMatter.status ?? "",
23
- priority: frontMatter.priority ?? "",
24
- owner: frontMatter.assignment?.owner,
25
- mode: frontMatter.assignment?.mode,
26
- last_updated: lastUpdated(path.dirname(ticketPath)),
27
- });
28
- }
29
-
30
- return rows;
31
- }
32
-
33
- function passesFilters(frontMatter, body, filters) {
34
- if (filters.status && frontMatter.status !== filters.status) {
35
- return false;
36
- }
37
- if (filters.priority && frontMatter.priority !== filters.priority) {
38
- return false;
39
- }
40
- if (filters.mode && frontMatter.assignment?.mode !== filters.mode) {
41
- return false;
42
- }
43
- if (filters.owner && frontMatter.assignment?.owner !== filters.owner) {
44
- return false;
45
- }
46
- if (filters.label) {
47
- const labels = Array.isArray(frontMatter.labels) ? frontMatter.labels : [];
48
- if (!labels.includes(filters.label)) {
49
- return false;
50
- }
51
- }
52
- if (filters.text) {
53
- const text = String(filters.text).toLowerCase();
54
- const haystack = `${frontMatter.title ?? ""}\n${body ?? ""}`.toLowerCase();
55
- if (!haystack.includes(text)) {
56
- return false;
57
- }
58
- }
59
- return true;
60
- }
61
-
62
- function lastUpdated(ticketDir) {
63
- const logsDir = path.join(ticketDir, "logs");
64
- let latest = "";
65
-
66
- if (!fs.existsSync(logsDir)) {
67
- return latest;
68
- }
69
-
70
- const logFiles = fs
71
- .readdirSync(logsDir, { withFileTypes: true })
72
- .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
73
- .map((entry) => path.join(logsDir, entry.name));
74
-
75
- for (const logFile of logFiles) {
76
- for (const entry of readJsonl(logFile)) {
77
- const ts = entry.ts;
78
- if (typeof ts === "string" && (latest === "" || ts > latest)) {
79
- latest = ts;
80
- }
81
- }
82
- }
83
-
84
- return latest;
1
+ import { listPlanningRows, sortPlanningRows } from "./planning.js";
2
+
3
+ export function listTickets(snapshot, filters, options = {}) {
4
+ const rows = sortPlanningRows(listPlanningRows(snapshot, filters), options.sortBy, options.reverse);
5
+ return rows.map((row) => ({
6
+ id: row.id,
7
+ title: row.title,
8
+ status: row.status,
9
+ priority: row.priority ?? "",
10
+ owner: row.owner,
11
+ mode: row.mode,
12
+ node_type: row.planning.node_type,
13
+ lane: row.planning.lane,
14
+ rank: row.planning.rank,
15
+ horizon: row.planning.horizon,
16
+ group_ids: row.planning.group_ids,
17
+ precedes: row.planning.precedes,
18
+ resolution: row.resolution,
19
+ ready: row.ready,
20
+ blocked_by: row.blocked_by,
21
+ active_claim: row.active_claim,
22
+ claim_summary: row.claim_summary,
23
+ last_updated: row.last_updated,
24
+ }));
85
25
  }