@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.
- package/.tickets/spec/AGENTS_EXAMPLE.md +6 -0
- package/.tickets/spec/TICKETS.md +44 -11
- package/.tickets/spec/profile/defaults.yml +2 -0
- package/.tickets/spec/version/20260205-tickets-spec.md +1 -1
- package/.tickets/spec/version/20260311-tickets-spec.md +1 -1
- package/.tickets/spec/version/20260317-2-tickets-spec.md +106 -0
- package/.tickets/spec/version/20260317-3-tickets-spec.md +121 -0
- package/.tickets/spec/version/20260317-4-tickets-spec.md +120 -0
- package/.tickets/spec/version/20260317-tickets-spec.md +1 -1
- package/README.md +25 -2
- package/package.json +1 -1
- package/release-history.json +14 -0
- package/src/cli.js +388 -54
- package/src/lib/config.js +14 -0
- package/src/lib/constants.js +6 -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 +13 -0
- package/src/lib/validation.js +454 -0
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,13 +1,18 @@
|
|
|
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-4-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
8
|
export const PLANNING_NODE_TYPES = ["work", "group", "checkpoint"];
|
|
9
9
|
export const RESOLUTION_VALUES = ["completed", "merged", "dropped"];
|
|
10
|
+
export const COMPLETION_ACCEPTANCE_VALUES = ["met", "not_met"];
|
|
11
|
+
export const COMPLETION_VERIFICATION_VALUES = ["passed", "failed", "not_run"];
|
|
10
12
|
export const CLAIM_ACTION_VALUES = ["acquire", "renew", "release", "override"];
|
|
11
13
|
export const WORKFLOW_MODE_VALUES = ["auto", "doc_first", "skill_first"];
|
|
12
14
|
export const GRAPH_VIEW_VALUES = ["dependency", "sequence", "portfolio", "all"];
|
|
13
15
|
export const DEFAULT_CLAIM_TTL_MINUTES = 60;
|
|
16
|
+
export const LIST_SORT_VALUES = ["ready", "priority", "lane", "rank", "updated", "title"];
|
|
17
|
+
export const PLANNING_INDEX_FORMAT_ID = "0195a1b7-4a17-7c2e-8db2-4d5cb0f0d642";
|
|
18
|
+
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
|
+
}
|
package/src/lib/listing.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { listPlanningRows } from "./planning.js";
|
|
1
|
+
import { listPlanningRows, sortPlanningRows } from "./planning.js";
|
|
2
2
|
|
|
3
|
-
export function listTickets(filters, options = {}) {
|
|
4
|
-
|
|
3
|
+
export function listTickets(snapshot, filters, options = {}) {
|
|
4
|
+
const rows = sortPlanningRows(listPlanningRows(snapshot, filters), options.sortBy, options.reverse);
|
|
5
|
+
return rows.map((row) => ({
|
|
5
6
|
id: row.id,
|
|
6
7
|
title: row.title,
|
|
7
8
|
status: row.status,
|
|
@@ -16,7 +17,9 @@ export function listTickets(filters, options = {}) {
|
|
|
16
17
|
precedes: row.planning.precedes,
|
|
17
18
|
resolution: row.resolution,
|
|
18
19
|
ready: row.ready,
|
|
20
|
+
blocked_by: row.blocked_by,
|
|
19
21
|
active_claim: row.active_claim,
|
|
22
|
+
claim_summary: row.claim_summary,
|
|
20
23
|
last_updated: row.last_updated,
|
|
21
24
|
}));
|
|
22
25
|
}
|