ai-foreman 1.0.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/README.md +523 -0
- package/dist/adapters/claude.js +150 -0
- package/dist/adapters/codex.js +155 -0
- package/dist/adapters/types.js +4 -0
- package/dist/cli/events.js +45 -0
- package/dist/cli/tickets.js +482 -0
- package/dist/config.js +119 -0
- package/dist/foreman.js +445 -0
- package/dist/index.js +300 -0
- package/dist/log.js +18 -0
- package/dist/markers.js +20 -0
- package/dist/notify.js +49 -0
- package/dist/permissions/policy.js +84 -0
- package/dist/roles.js +49 -0
- package/dist/tickets/blockers.js +22 -0
- package/dist/tickets/commands.js +438 -0
- package/dist/tickets/config.js +133 -0
- package/dist/tickets/events.js +19 -0
- package/dist/tickets/importer.js +11 -0
- package/dist/tickets/queue.js +62 -0
- package/dist/tickets/renderMarkdown.js +223 -0
- package/dist/tickets/stateDb.js +252 -0
- package/dist/tickets/ticketLoader.js +84 -0
- package/dist/tickets/ticketSchema.js +31 -0
- package/dist/tickets/validate.js +74 -0
- package/dist/util/asyncQueue.js +40 -0
- package/foreman.yaml +98 -0
- package/package.json +60 -0
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { stringify } from "yaml";
|
|
4
|
+
import { loadTicketsConfig, resolveTicketPaths, isTicketsInitialized } from "./config.js";
|
|
5
|
+
import { loadTickets, saveTickets } from "./ticketLoader.js";
|
|
6
|
+
import { StateDb } from "./stateDb.js";
|
|
7
|
+
import { nowTimestamp, logEvent } from "./events.js";
|
|
8
|
+
import { renderAndWrite, DEFAULT_TRACKER_RULES } from "./renderMarkdown.js";
|
|
9
|
+
import { runAllValidation } from "./validate.js";
|
|
10
|
+
import { buildNextQueue } from "./queue.js";
|
|
11
|
+
export function openContext(projectDir) {
|
|
12
|
+
const config = loadTicketsConfig(projectDir);
|
|
13
|
+
const paths = resolveTicketPaths(config, projectDir);
|
|
14
|
+
const tickets = loadTickets(paths.tickets);
|
|
15
|
+
const db = new StateDb(paths.stateDb);
|
|
16
|
+
return { config, projectDir, paths, tickets, db };
|
|
17
|
+
}
|
|
18
|
+
function withContext(projectDir, fn) {
|
|
19
|
+
const ctx = openContext(projectDir);
|
|
20
|
+
try {
|
|
21
|
+
return fn(ctx);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
ctx.db.close();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function renderAfterUpdate(ctx) {
|
|
28
|
+
const states = ctx.db.getAllStates();
|
|
29
|
+
renderAndWrite({ config: ctx.config, projectDir: ctx.projectDir, ticketDefs: ctx.tickets, states, db: ctx.db });
|
|
30
|
+
}
|
|
31
|
+
export function cmdInit(projectDir, opts) {
|
|
32
|
+
if (isTicketsInitialized(projectDir)) {
|
|
33
|
+
throw new Error(".tickets/config.yaml already exists — already initialized.");
|
|
34
|
+
}
|
|
35
|
+
const ticketsDir = join(projectDir, ".tickets");
|
|
36
|
+
mkdirSync(join(ticketsDir, "schema"), { recursive: true });
|
|
37
|
+
mkdirSync(join(ticketsDir, "migrations"), { recursive: true });
|
|
38
|
+
mkdirSync(join(ticketsDir, "backups"), { recursive: true });
|
|
39
|
+
mkdirSync(join(projectDir, "docs"), { recursive: true });
|
|
40
|
+
const config = {
|
|
41
|
+
app_name: opts.appName ?? "My App",
|
|
42
|
+
queue_limit: opts.queueLimit ?? 50,
|
|
43
|
+
timezone: opts.timezone ?? "UTC",
|
|
44
|
+
timestamp_format: "iso8601_offset",
|
|
45
|
+
paths: {
|
|
46
|
+
tickets: ".tickets/tickets.yaml",
|
|
47
|
+
state_db: ".tickets/ticket-state.sqlite",
|
|
48
|
+
tracker_rules: ".tickets/tracker-rules.md",
|
|
49
|
+
progress_doc: "docs/ticket-progress.md",
|
|
50
|
+
archive_doc: "docs/ticket-archive.md",
|
|
51
|
+
},
|
|
52
|
+
rendering: {
|
|
53
|
+
preserve_legacy_llm_queue_heading: true,
|
|
54
|
+
include_rules_in_progress_doc: true,
|
|
55
|
+
max_work_log_rows: 50,
|
|
56
|
+
max_validation_snapshot_rows: 20,
|
|
57
|
+
max_recent_completed_rows: 20,
|
|
58
|
+
generated_doc_warning: true,
|
|
59
|
+
},
|
|
60
|
+
behavior: {
|
|
61
|
+
regenerate_progress_doc_after_every_update: true,
|
|
62
|
+
require_validation_evidence_for_done: true,
|
|
63
|
+
block_on_unresolved_dependencies: true,
|
|
64
|
+
use_atomic_file_writes: true,
|
|
65
|
+
backup_before_import_or_migration: true,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
writeFileSync(join(ticketsDir, "config.yaml"), stringify(config, { lineWidth: 80 }), "utf8");
|
|
69
|
+
writeFileSync(join(ticketsDir, "tickets.yaml"), "tickets: []\n", "utf8");
|
|
70
|
+
writeFileSync(join(ticketsDir, "tracker-rules.md"), DEFAULT_TRACKER_RULES, "utf8");
|
|
71
|
+
const ticketSchema = {
|
|
72
|
+
$schema: "http://json-schema.org/draft-07/schema#",
|
|
73
|
+
title: "Ticket",
|
|
74
|
+
type: "object",
|
|
75
|
+
required: ["id", "order", "title", "area", "priority", "size", "risk",
|
|
76
|
+
"depends_on", "summary", "acceptance", "required_tests", "likely_files"],
|
|
77
|
+
properties: {
|
|
78
|
+
id: { type: "string", pattern: "^[A-Za-z][A-Za-z0-9-_]*$" },
|
|
79
|
+
order: { type: "number", minimum: 0 },
|
|
80
|
+
title: { type: "string" },
|
|
81
|
+
area: { type: "string" },
|
|
82
|
+
priority: { type: "string", enum: ["P0", "P1", "P2", "P3"] },
|
|
83
|
+
size: { type: "string", enum: ["XS", "S", "M", "L", "XL"] },
|
|
84
|
+
risk: { type: "string", enum: ["Low", "Medium", "High"] },
|
|
85
|
+
depends_on: { type: "array", items: { type: "string" } },
|
|
86
|
+
summary: { type: "string" },
|
|
87
|
+
acceptance: { type: "array", items: { type: "string" }, minItems: 1 },
|
|
88
|
+
required_tests: { type: "array", items: { type: "string" }, minItems: 1 },
|
|
89
|
+
likely_files: { type: "array", items: { type: "string" } },
|
|
90
|
+
rollback: { type: ["string", "null"] },
|
|
91
|
+
notes: { type: ["string", "null"] },
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
writeFileSync(join(ticketsDir, "schema", "tickets.schema.json"), JSON.stringify(ticketSchema, null, 2), "utf8");
|
|
95
|
+
writeFileSync(join(ticketsDir, "migrations", "001_init.sql"), "-- Auto-applied by foreman tickets init via the StateDb class.\n" +
|
|
96
|
+
"-- See src/tickets/stateDb.ts for the full SQL schema.\n", "utf8");
|
|
97
|
+
// Open DB (runs migrations), render initial doc, close
|
|
98
|
+
const loadedConfig = loadTicketsConfig(projectDir);
|
|
99
|
+
const paths = resolveTicketPaths(loadedConfig, projectDir);
|
|
100
|
+
const db = new StateDb(paths.stateDb);
|
|
101
|
+
try {
|
|
102
|
+
const states = db.getAllStates();
|
|
103
|
+
renderAndWrite({ config: loadedConfig, projectDir, ticketDefs: [], states, db });
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
db.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function cmdUpdate(projectDir, ticketId, opts) {
|
|
110
|
+
withContext(projectDir, (ctx) => {
|
|
111
|
+
const now = nowTimestamp(ctx.config.timezone);
|
|
112
|
+
const state = ctx.db.getState(ticketId);
|
|
113
|
+
const oldStatus = state?.status ?? null;
|
|
114
|
+
const patch = { last_worked_at: now };
|
|
115
|
+
if (opts.status)
|
|
116
|
+
patch.status = opts.status;
|
|
117
|
+
if (opts.nextAction !== undefined)
|
|
118
|
+
patch.next_action = opts.nextAction;
|
|
119
|
+
if (opts.currentStep !== undefined)
|
|
120
|
+
patch.current_step = opts.currentStep;
|
|
121
|
+
if (opts.owner !== undefined)
|
|
122
|
+
patch.owner = opts.owner;
|
|
123
|
+
if (opts.validationResult !== undefined)
|
|
124
|
+
patch.validation_result = opts.validationResult;
|
|
125
|
+
if (opts.validationCommands !== undefined)
|
|
126
|
+
patch.validation_commands = opts.validationCommands;
|
|
127
|
+
if (opts.validationNotes !== undefined)
|
|
128
|
+
patch.validation_notes = opts.validationNotes;
|
|
129
|
+
if (opts.evidence !== undefined)
|
|
130
|
+
patch.evidence = opts.evidence;
|
|
131
|
+
if (opts.lastError !== undefined)
|
|
132
|
+
patch.last_error = opts.lastError;
|
|
133
|
+
if (opts.status === "done" || opts.status === "canceled")
|
|
134
|
+
patch.completed_at = now;
|
|
135
|
+
ctx.db.transaction(() => {
|
|
136
|
+
ctx.db.upsertState(ticketId, patch, now);
|
|
137
|
+
logEvent(ctx.db, {
|
|
138
|
+
timestamp: now,
|
|
139
|
+
actor: opts.actor ?? null,
|
|
140
|
+
ticketId,
|
|
141
|
+
eventType: "update",
|
|
142
|
+
oldStatus,
|
|
143
|
+
newStatus: opts.status ?? null,
|
|
144
|
+
summary: opts.summary ?? `Updated ticket ${ticketId}`,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
renderAfterUpdate(ctx);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
export function cmdComplete(projectDir, ticketId, opts) {
|
|
151
|
+
withContext(projectDir, (ctx) => {
|
|
152
|
+
const requireEvidence = ctx.config.behavior.requireValidationEvidenceForDone;
|
|
153
|
+
const vr = opts.validationResult ?? "not_run";
|
|
154
|
+
if (requireEvidence && !opts.evidence && vr !== "not_applicable") {
|
|
155
|
+
throw new Error(`--evidence is required to mark a ticket done (config.behavior.requireValidationEvidenceForDone=true).\n` +
|
|
156
|
+
`Use --validation-result not_applicable --validation-notes "<reason>" to skip.`);
|
|
157
|
+
}
|
|
158
|
+
const now = nowTimestamp(ctx.config.timezone);
|
|
159
|
+
const state = ctx.db.getState(ticketId);
|
|
160
|
+
const oldStatus = state?.status ?? null;
|
|
161
|
+
ctx.db.transaction(() => {
|
|
162
|
+
ctx.db.upsertState(ticketId, {
|
|
163
|
+
status: "done",
|
|
164
|
+
completed_at: now,
|
|
165
|
+
last_worked_at: now,
|
|
166
|
+
validation_result: vr,
|
|
167
|
+
validation_commands: opts.validationCommands ?? null,
|
|
168
|
+
validation_notes: opts.validationNotes ?? null,
|
|
169
|
+
evidence: opts.evidence ?? null,
|
|
170
|
+
attempt_count: (state?.attempt_count ?? 0) + 1,
|
|
171
|
+
}, now);
|
|
172
|
+
if (opts.validationResult && opts.evidence) {
|
|
173
|
+
ctx.db.insertValidationSnapshot({
|
|
174
|
+
timestamp: now,
|
|
175
|
+
scope: ticketId,
|
|
176
|
+
result: opts.validationResult,
|
|
177
|
+
commands: opts.validationCommands ?? null,
|
|
178
|
+
evidence: opts.evidence,
|
|
179
|
+
notes: opts.validationNotes ?? null,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
logEvent(ctx.db, {
|
|
183
|
+
timestamp: now,
|
|
184
|
+
actor: opts.actor ?? null,
|
|
185
|
+
ticketId,
|
|
186
|
+
eventType: "complete",
|
|
187
|
+
oldStatus,
|
|
188
|
+
newStatus: "done",
|
|
189
|
+
summary: opts.summary ?? `Completed ticket ${ticketId}`,
|
|
190
|
+
validation: opts.validationResult ?? null,
|
|
191
|
+
evidence: opts.evidence ?? null,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
renderAfterUpdate(ctx);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
export function cmdBlock(projectDir, ticketId, opts) {
|
|
198
|
+
withContext(projectDir, (ctx) => {
|
|
199
|
+
const now = nowTimestamp(ctx.config.timezone);
|
|
200
|
+
const state = ctx.db.getState(ticketId);
|
|
201
|
+
const oldStatus = state?.status ?? null;
|
|
202
|
+
const existing = JSON.parse(state?.blocked_by_json ?? "[]");
|
|
203
|
+
const merged = [...new Set([...existing, ...(opts.blockedBy ?? [])])];
|
|
204
|
+
ctx.db.transaction(() => {
|
|
205
|
+
ctx.db.upsertState(ticketId, {
|
|
206
|
+
status: "blocked",
|
|
207
|
+
blocked_by_json: JSON.stringify(merged),
|
|
208
|
+
blocker_type: opts.blockerType ?? "dependency",
|
|
209
|
+
blocker_notes: opts.unblockCriteria ?? null,
|
|
210
|
+
first_blocked_at: state?.first_blocked_at ?? now,
|
|
211
|
+
last_checked_at: now,
|
|
212
|
+
last_worked_at: now,
|
|
213
|
+
}, now);
|
|
214
|
+
logEvent(ctx.db, {
|
|
215
|
+
timestamp: now,
|
|
216
|
+
actor: opts.actor ?? null,
|
|
217
|
+
ticketId,
|
|
218
|
+
eventType: "block",
|
|
219
|
+
oldStatus,
|
|
220
|
+
newStatus: "blocked",
|
|
221
|
+
summary: opts.summary ?? `Blocked ticket ${ticketId} by ${merged.join(", ")}`,
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
renderAfterUpdate(ctx);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
// ── unblock ───────────────────────────────────────────────────────────────────
|
|
228
|
+
export function cmdUnblock(projectDir, ticketId, opts) {
|
|
229
|
+
withContext(projectDir, (ctx) => {
|
|
230
|
+
const now = nowTimestamp(ctx.config.timezone);
|
|
231
|
+
const state = ctx.db.getState(ticketId);
|
|
232
|
+
const oldStatus = state?.status ?? null;
|
|
233
|
+
ctx.db.transaction(() => {
|
|
234
|
+
ctx.db.upsertState(ticketId, {
|
|
235
|
+
status: "planned",
|
|
236
|
+
blocked_by_json: "[]",
|
|
237
|
+
blocker_type: null,
|
|
238
|
+
blocker_notes: null,
|
|
239
|
+
first_blocked_at: null,
|
|
240
|
+
last_checked_at: null,
|
|
241
|
+
last_worked_at: now,
|
|
242
|
+
}, now);
|
|
243
|
+
logEvent(ctx.db, {
|
|
244
|
+
timestamp: now,
|
|
245
|
+
actor: opts.actor ?? null,
|
|
246
|
+
ticketId,
|
|
247
|
+
eventType: "unblock",
|
|
248
|
+
oldStatus,
|
|
249
|
+
newStatus: "planned",
|
|
250
|
+
summary: opts.summary ?? `Unblocked ticket ${ticketId}`,
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
renderAfterUpdate(ctx);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
// ── cancel ────────────────────────────────────────────────────────────────────
|
|
257
|
+
export function cmdCancel(projectDir, ticketId, opts) {
|
|
258
|
+
withContext(projectDir, (ctx) => {
|
|
259
|
+
const now = nowTimestamp(ctx.config.timezone);
|
|
260
|
+
const state = ctx.db.getState(ticketId);
|
|
261
|
+
const oldStatus = state?.status ?? null;
|
|
262
|
+
ctx.db.transaction(() => {
|
|
263
|
+
ctx.db.upsertState(ticketId, {
|
|
264
|
+
status: "canceled",
|
|
265
|
+
completed_at: now,
|
|
266
|
+
last_worked_at: now,
|
|
267
|
+
}, now);
|
|
268
|
+
logEvent(ctx.db, {
|
|
269
|
+
timestamp: now,
|
|
270
|
+
actor: opts.actor ?? null,
|
|
271
|
+
ticketId,
|
|
272
|
+
eventType: "cancel",
|
|
273
|
+
oldStatus,
|
|
274
|
+
newStatus: "canceled",
|
|
275
|
+
summary: opts.summary,
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
renderAfterUpdate(ctx);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
export function cmdDiscover(projectDir, opts) {
|
|
282
|
+
return withContext(projectDir, (ctx) => {
|
|
283
|
+
const now = nowTimestamp(ctx.config.timezone);
|
|
284
|
+
let id;
|
|
285
|
+
ctx.db.transaction(() => {
|
|
286
|
+
id = ctx.db.insertFutureWork({
|
|
287
|
+
discovered_at: now,
|
|
288
|
+
source_ticket: opts.sourceTicket ?? null,
|
|
289
|
+
proposed_ticket: opts.proposedTicket ?? null,
|
|
290
|
+
priority_guess: opts.priorityGuess ?? null,
|
|
291
|
+
area: opts.area ?? null,
|
|
292
|
+
summary: opts.summary,
|
|
293
|
+
rationale: opts.rationale ?? null,
|
|
294
|
+
needs_decision_from: opts.needsDecisionFrom ?? null,
|
|
295
|
+
disposition: "triage",
|
|
296
|
+
});
|
|
297
|
+
logEvent(ctx.db, {
|
|
298
|
+
timestamp: now,
|
|
299
|
+
actor: opts.actor ?? null,
|
|
300
|
+
ticketId: opts.sourceTicket ?? null,
|
|
301
|
+
eventType: "discover",
|
|
302
|
+
oldStatus: null,
|
|
303
|
+
newStatus: null,
|
|
304
|
+
summary: `Discovered future work: ${opts.summary}`,
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
renderAfterUpdate(ctx);
|
|
308
|
+
return id;
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
// ── accept-future-work ────────────────────────────────────────────────────────
|
|
312
|
+
export function cmdAcceptFutureWork(projectDir, futureWorkId, opts) {
|
|
313
|
+
withContext(projectDir, (ctx) => {
|
|
314
|
+
const fw = ctx.db.getFutureWorkById(futureWorkId);
|
|
315
|
+
if (!fw)
|
|
316
|
+
throw new Error(`Future work item ${futureWorkId} not found`);
|
|
317
|
+
const now = nowTimestamp(ctx.config.timezone);
|
|
318
|
+
const newTicket = {
|
|
319
|
+
id: opts.ticketId,
|
|
320
|
+
order: opts.order,
|
|
321
|
+
title: fw.summary,
|
|
322
|
+
area: fw.area ?? "General",
|
|
323
|
+
priority: fw.priority_guess ?? "P2",
|
|
324
|
+
size: "M",
|
|
325
|
+
risk: "Low",
|
|
326
|
+
depends_on: [],
|
|
327
|
+
summary: fw.summary,
|
|
328
|
+
acceptance: ["TODO: fill acceptance criteria"],
|
|
329
|
+
required_tests: ["TODO: fill required tests"],
|
|
330
|
+
likely_files: [],
|
|
331
|
+
rollback: null,
|
|
332
|
+
notes: fw.rationale ?? null,
|
|
333
|
+
};
|
|
334
|
+
ctx.db.transaction(() => {
|
|
335
|
+
ctx.db.updateFutureWorkDisposition(futureWorkId, "accepted");
|
|
336
|
+
logEvent(ctx.db, {
|
|
337
|
+
timestamp: now,
|
|
338
|
+
actor: opts.actor ?? null,
|
|
339
|
+
ticketId: opts.ticketId,
|
|
340
|
+
eventType: "accept-future-work",
|
|
341
|
+
oldStatus: null,
|
|
342
|
+
newStatus: "planned",
|
|
343
|
+
summary: `Accepted future work ${futureWorkId} as ticket ${opts.ticketId}`,
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
saveTickets(ctx.paths.tickets, [...ctx.tickets, newTicket]);
|
|
347
|
+
// Re-open context to pick up new ticket list
|
|
348
|
+
const newCtx = openContext(projectDir);
|
|
349
|
+
try {
|
|
350
|
+
renderAfterUpdate(newCtx);
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
newCtx.db.close();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
// ── reorder ───────────────────────────────────────────────────────────────────
|
|
358
|
+
export function cmdReorder(projectDir, ticketId, opts) {
|
|
359
|
+
withContext(projectDir, (ctx) => {
|
|
360
|
+
const ticket = ctx.tickets.find((t) => t.id === ticketId);
|
|
361
|
+
if (!ticket)
|
|
362
|
+
throw new Error(`Ticket ${ticketId} not found`);
|
|
363
|
+
let newOrder;
|
|
364
|
+
if (opts.order !== undefined) {
|
|
365
|
+
newOrder = opts.order;
|
|
366
|
+
}
|
|
367
|
+
else if (opts.afterTicketId) {
|
|
368
|
+
const afterTicket = ctx.tickets.find((t) => t.id === opts.afterTicketId);
|
|
369
|
+
if (!afterTicket)
|
|
370
|
+
throw new Error(`Ticket ${opts.afterTicketId} not found`);
|
|
371
|
+
const sorted = [...ctx.tickets].sort((a, b) => a.order - b.order);
|
|
372
|
+
const afterIdx = sorted.findIndex((t) => t.id === opts.afterTicketId);
|
|
373
|
+
const next = sorted[afterIdx + 1];
|
|
374
|
+
newOrder = next ? Math.floor((afterTicket.order + next.order) / 2) : afterTicket.order + 1000;
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
throw new Error("Provide --after <ticketId> or --order <n>");
|
|
378
|
+
}
|
|
379
|
+
const now = nowTimestamp(ctx.config.timezone);
|
|
380
|
+
const updated = ctx.tickets.map((t) => t.id === ticketId ? { ...t, order: newOrder } : t);
|
|
381
|
+
saveTickets(ctx.paths.tickets, updated);
|
|
382
|
+
ctx.db.transaction(() => {
|
|
383
|
+
logEvent(ctx.db, {
|
|
384
|
+
timestamp: now,
|
|
385
|
+
actor: opts.actor ?? null,
|
|
386
|
+
ticketId,
|
|
387
|
+
eventType: "reorder",
|
|
388
|
+
oldStatus: null,
|
|
389
|
+
newStatus: null,
|
|
390
|
+
summary: `Reordered ticket ${ticketId} to order ${newOrder}`,
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
// Re-open with updated tickets
|
|
394
|
+
const newCtx = openContext(projectDir);
|
|
395
|
+
try {
|
|
396
|
+
renderAfterUpdate(newCtx);
|
|
397
|
+
}
|
|
398
|
+
finally {
|
|
399
|
+
newCtx.db.close();
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
// ── render ────────────────────────────────────────────────────────────────────
|
|
404
|
+
export function cmdRender(projectDir) {
|
|
405
|
+
withContext(projectDir, (ctx) => {
|
|
406
|
+
renderAfterUpdate(ctx);
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
export function cmdValidate(projectDir) {
|
|
410
|
+
return withContext(projectDir, (ctx) => {
|
|
411
|
+
const states = ctx.db.getAllStates();
|
|
412
|
+
const issues = runAllValidation(ctx.config, projectDir, ctx.tickets, states, ctx.db);
|
|
413
|
+
return { issues, clean: issues.filter((i) => i.severity === "error").length === 0 };
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
// ── queue ─────────────────────────────────────────────────────────────────────
|
|
417
|
+
export function cmdQueue(projectDir, limit) {
|
|
418
|
+
return withContext(projectDir, (ctx) => {
|
|
419
|
+
const states = ctx.db.getAllStates();
|
|
420
|
+
return buildNextQueue(ctx.tickets, states, limit ?? ctx.config.queueLimit);
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
// ── archive ───────────────────────────────────────────────────────────────────
|
|
424
|
+
export function cmdArchive(projectDir, _opts) {
|
|
425
|
+
withContext(projectDir, (ctx) => {
|
|
426
|
+
const now = nowTimestamp(ctx.config.timezone);
|
|
427
|
+
logEvent(ctx.db, {
|
|
428
|
+
timestamp: now,
|
|
429
|
+
actor: "system",
|
|
430
|
+
ticketId: null,
|
|
431
|
+
eventType: "archive",
|
|
432
|
+
oldStatus: null,
|
|
433
|
+
newStatus: null,
|
|
434
|
+
summary: "Archive pass executed",
|
|
435
|
+
});
|
|
436
|
+
renderAfterUpdate(ctx);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
export const DEFAULT_TICKETS_CONFIG = {
|
|
5
|
+
appName: "My App",
|
|
6
|
+
queueLimit: 50,
|
|
7
|
+
timezone: "UTC",
|
|
8
|
+
paths: {
|
|
9
|
+
tickets: ".tickets/tickets.yaml",
|
|
10
|
+
stateDb: ".tickets/ticket-state.sqlite",
|
|
11
|
+
trackerRules: ".tickets/tracker-rules.md",
|
|
12
|
+
progressDoc: "docs/ticket-progress.md",
|
|
13
|
+
archiveDoc: "docs/ticket-archive.md",
|
|
14
|
+
},
|
|
15
|
+
rendering: {
|
|
16
|
+
preserveLegacyLlmQueueHeading: true,
|
|
17
|
+
includeRulesInProgressDoc: true,
|
|
18
|
+
maxWorkLogRows: 50,
|
|
19
|
+
maxValidationSnapshotRows: 20,
|
|
20
|
+
maxRecentCompletedRows: 20,
|
|
21
|
+
generatedDocWarning: true,
|
|
22
|
+
},
|
|
23
|
+
behavior: {
|
|
24
|
+
regenerateProgressDocAfterEveryUpdate: true,
|
|
25
|
+
requireValidationEvidenceForDone: true,
|
|
26
|
+
blockOnUnresolvedDependencies: true,
|
|
27
|
+
useAtomicFileWrites: true,
|
|
28
|
+
backupBeforeImportOrMigration: true,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
function snakeToCamel(s) {
|
|
32
|
+
return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
33
|
+
}
|
|
34
|
+
function camelizeObject(obj) {
|
|
35
|
+
const result = {};
|
|
36
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
37
|
+
result[snakeToCamel(k)] = v;
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
export function loadTicketsConfig(projectDir) {
|
|
42
|
+
const configPath = join(projectDir, ".tickets", "config.yaml");
|
|
43
|
+
if (!existsSync(configPath)) {
|
|
44
|
+
return {
|
|
45
|
+
...DEFAULT_TICKETS_CONFIG,
|
|
46
|
+
paths: { ...DEFAULT_TICKETS_CONFIG.paths },
|
|
47
|
+
rendering: { ...DEFAULT_TICKETS_CONFIG.rendering },
|
|
48
|
+
behavior: { ...DEFAULT_TICKETS_CONFIG.behavior },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const raw = parse(readFileSync(configPath, "utf8")) ?? {};
|
|
52
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
53
|
+
throw new Error(`${configPath}: expected a YAML object`);
|
|
54
|
+
}
|
|
55
|
+
assertPlainObject(raw.paths, "paths", configPath);
|
|
56
|
+
assertPlainObject(raw.rendering, "rendering", configPath);
|
|
57
|
+
assertPlainObject(raw.behavior, "behavior", configPath);
|
|
58
|
+
const config = {
|
|
59
|
+
appName: raw.app_name ?? DEFAULT_TICKETS_CONFIG.appName,
|
|
60
|
+
queueLimit: raw.queue_limit ?? DEFAULT_TICKETS_CONFIG.queueLimit,
|
|
61
|
+
timezone: raw.timezone ?? DEFAULT_TICKETS_CONFIG.timezone,
|
|
62
|
+
paths: {
|
|
63
|
+
...DEFAULT_TICKETS_CONFIG.paths,
|
|
64
|
+
...(raw.paths ? camelizeObject(raw.paths) : {}),
|
|
65
|
+
},
|
|
66
|
+
rendering: {
|
|
67
|
+
...DEFAULT_TICKETS_CONFIG.rendering,
|
|
68
|
+
...(raw.rendering ? camelizeObject(raw.rendering) : {}),
|
|
69
|
+
},
|
|
70
|
+
behavior: {
|
|
71
|
+
...DEFAULT_TICKETS_CONFIG.behavior,
|
|
72
|
+
...(raw.behavior ? camelizeObject(raw.behavior) : {}),
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
validateTicketsConfig(config, configPath);
|
|
76
|
+
return config;
|
|
77
|
+
}
|
|
78
|
+
function assertPlainObject(value, name, configPath) {
|
|
79
|
+
if (value !== undefined && (!value || typeof value !== "object" || Array.isArray(value))) {
|
|
80
|
+
throw new Error(`${configPath}: ${name} must be an object`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function validateTicketsConfig(config, configPath) {
|
|
84
|
+
if (!config.appName || typeof config.appName !== "string") {
|
|
85
|
+
throw new Error(`${configPath}: app_name must be a non-empty string`);
|
|
86
|
+
}
|
|
87
|
+
if (!Number.isInteger(config.queueLimit) || config.queueLimit < 1) {
|
|
88
|
+
throw new Error(`${configPath}: queue_limit must be a positive integer`);
|
|
89
|
+
}
|
|
90
|
+
if (!config.timezone || typeof config.timezone !== "string") {
|
|
91
|
+
throw new Error(`${configPath}: timezone must be a non-empty IANA timezone string`);
|
|
92
|
+
}
|
|
93
|
+
for (const [key, value] of Object.entries(config.paths)) {
|
|
94
|
+
if (!value || typeof value !== "string") {
|
|
95
|
+
throw new Error(`${configPath}: paths.${camelToSnake(key)} must be a non-empty string`);
|
|
96
|
+
}
|
|
97
|
+
if (isAbsolute(value)) {
|
|
98
|
+
throw new Error(`${configPath}: paths.${camelToSnake(key)} must be repo-relative`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
for (const [key, value] of Object.entries(config.rendering)) {
|
|
102
|
+
if (key.startsWith("max")) {
|
|
103
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
104
|
+
throw new Error(`${configPath}: rendering.${camelToSnake(key)} must be a positive integer`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else if (typeof value !== "boolean") {
|
|
108
|
+
throw new Error(`${configPath}: rendering.${camelToSnake(key)} must be a boolean`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const [key, value] of Object.entries(config.behavior)) {
|
|
112
|
+
if (typeof value !== "boolean") {
|
|
113
|
+
throw new Error(`${configPath}: behavior.${camelToSnake(key)} must be a boolean`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function camelToSnake(s) {
|
|
118
|
+
return s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
119
|
+
}
|
|
120
|
+
export function isTicketsInitialized(projectDir) {
|
|
121
|
+
return existsSync(join(projectDir, ".tickets", "config.yaml"));
|
|
122
|
+
}
|
|
123
|
+
export function resolveTicketPaths(config, projectDir) {
|
|
124
|
+
const p = config.paths;
|
|
125
|
+
const r = (rel) => join(projectDir, rel);
|
|
126
|
+
return {
|
|
127
|
+
tickets: r(p.tickets),
|
|
128
|
+
stateDb: r(p.stateDb),
|
|
129
|
+
trackerRules: r(p.trackerRules),
|
|
130
|
+
progressDoc: r(p.progressDoc),
|
|
131
|
+
archiveDoc: r(p.archiveDoc),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { DateTime } from "luxon";
|
|
2
|
+
export function nowTimestamp(timezone) {
|
|
3
|
+
return DateTime.now().setZone(timezone).toISO() ?? new Date().toISOString();
|
|
4
|
+
}
|
|
5
|
+
export function logEvent(db, opts) {
|
|
6
|
+
const event = {
|
|
7
|
+
timestamp: opts.timestamp,
|
|
8
|
+
actor: opts.actor,
|
|
9
|
+
ticket_id: opts.ticketId,
|
|
10
|
+
event_type: opts.eventType,
|
|
11
|
+
old_status: opts.oldStatus,
|
|
12
|
+
new_status: opts.newStatus,
|
|
13
|
+
summary: opts.summary,
|
|
14
|
+
validation: opts.validation ?? null,
|
|
15
|
+
evidence: opts.evidence ?? null,
|
|
16
|
+
payload_json: JSON.stringify(opts.payload ?? {}),
|
|
17
|
+
};
|
|
18
|
+
db.insertEvent(event);
|
|
19
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function importFromMarkdown(_progressDocPath) {
|
|
2
|
+
throw new Error("foreman tickets import is not yet implemented.\n\n" +
|
|
3
|
+
"To start fresh:\n" +
|
|
4
|
+
" 1. Run `foreman tickets init --app-name \"My App\" --timezone America/Chicago`\n" +
|
|
5
|
+
" 2. Add your tickets to .tickets/tickets.yaml\n" +
|
|
6
|
+
" 3. Run `foreman tickets render`\n\n" +
|
|
7
|
+
"Manual migration steps:\n" +
|
|
8
|
+
" 1. Copy ticket definitions into .tickets/tickets.yaml following the schema.\n" +
|
|
9
|
+
" 2. Run `foreman tickets validate` to check the structure.\n" +
|
|
10
|
+
" 3. Use `foreman tickets update <id> --status <status>` to restore active ticket states.");
|
|
11
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { resolveBlockers, computeDisplayStatus } from "./blockers.js";
|
|
2
|
+
function defaultNextAction(ticket, displayStatus, blockedBy) {
|
|
3
|
+
if (displayStatus === "blocked")
|
|
4
|
+
return `Resolve blockers: ${blockedBy.join(", ")}`;
|
|
5
|
+
if (displayStatus === "in_progress")
|
|
6
|
+
return "Continue implementation";
|
|
7
|
+
return "Begin implementation";
|
|
8
|
+
}
|
|
9
|
+
export function buildNextQueue(ticketDefs, states, queueLimit) {
|
|
10
|
+
const remaining = ticketDefs
|
|
11
|
+
.filter((t) => {
|
|
12
|
+
const s = states.get(t.id);
|
|
13
|
+
const status = s?.status ?? "planned";
|
|
14
|
+
return status !== "done" && status !== "canceled";
|
|
15
|
+
})
|
|
16
|
+
.sort((a, b) => a.order - b.order);
|
|
17
|
+
const window = remaining.slice(0, Math.min(queueLimit, remaining.length));
|
|
18
|
+
return window.map((ticket, index) => {
|
|
19
|
+
const state = states.get(ticket.id);
|
|
20
|
+
const blockedBy = resolveBlockers(ticket, states);
|
|
21
|
+
const displayStatus = computeDisplayStatus(state?.status ?? "planned", blockedBy);
|
|
22
|
+
return {
|
|
23
|
+
rank: index + 1,
|
|
24
|
+
ticket: ticket.id,
|
|
25
|
+
title: ticket.title,
|
|
26
|
+
status: displayStatus,
|
|
27
|
+
priority: ticket.priority,
|
|
28
|
+
area: ticket.area,
|
|
29
|
+
dependsOn: ticket.depends_on.length ? ticket.depends_on.join(", ") : "None",
|
|
30
|
+
blockedBy: blockedBy.length ? blockedBy.join(", ") : "None",
|
|
31
|
+
size: ticket.size,
|
|
32
|
+
risk: ticket.risk,
|
|
33
|
+
nextAction: state?.next_action ?? defaultNextAction(ticket, displayStatus, blockedBy),
|
|
34
|
+
requiredTests: ticket.required_tests.join("; "),
|
|
35
|
+
evidence: state?.evidence ?? "N/A until implemented.",
|
|
36
|
+
likelyFiles: ticket.likely_files.length ? ticket.likely_files.join(", ") : "unknown",
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
export function buildActiveStatusRows(queueRows, ticketDefs, states) {
|
|
41
|
+
const defsById = new Map(ticketDefs.map((t) => [t.id, t]));
|
|
42
|
+
return queueRows.map((row) => {
|
|
43
|
+
const state = states.get(row.ticket);
|
|
44
|
+
const def = defsById.get(row.ticket);
|
|
45
|
+
return {
|
|
46
|
+
ticket: row.ticket,
|
|
47
|
+
title: row.title,
|
|
48
|
+
status: row.status,
|
|
49
|
+
priority: row.priority,
|
|
50
|
+
area: row.area,
|
|
51
|
+
owner: state?.owner ?? "unassigned",
|
|
52
|
+
lastWorkedAt: state?.last_worked_at ?? "N/A",
|
|
53
|
+
completedAt: state?.completed_at ?? "N/A",
|
|
54
|
+
dependsOn: row.dependsOn,
|
|
55
|
+
blockers: row.blockedBy,
|
|
56
|
+
nextAction: row.nextAction,
|
|
57
|
+
acceptanceTestGate: def?.acceptance.join("; ") ?? "N/A",
|
|
58
|
+
evidence: row.evidence,
|
|
59
|
+
futureWorkNotes: state?.blocker_notes ?? "N/A",
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|