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.
@@ -0,0 +1,223 @@
1
+ import { writeFileSync, readFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { DateTime } from "luxon";
4
+ import { buildNextQueue, buildActiveStatusRows } from "./queue.js";
5
+ // ── Table helpers ─────────────────────────────────────────────────────────────
6
+ function esc(v) {
7
+ return v.replace(/\|/g, "\\|").replace(/\r?\n/g, "<br>");
8
+ }
9
+ function table(headers, rows) {
10
+ const sep = `|${headers.map(() => "---").join("|")}|`;
11
+ const head = `| ${headers.join(" | ")} |`;
12
+ const body = rows.map((r) => `| ${r.map(esc).join(" | ")} |`).join("\n");
13
+ return body ? `${head}\n${sep}\n${body}` : `${head}\n${sep}`;
14
+ }
15
+ // ── Section helpers ───────────────────────────────────────────────────────────
16
+ function section(marker, content) {
17
+ return `<!-- ${marker}_START -->\n${content}\n<!-- ${marker}_END -->`;
18
+ }
19
+ // ── Default tracker-rules content ─────────────────────────────────────────────
20
+ export const DEFAULT_TRACKER_RULES = `# Tracker Rules
21
+
22
+ ## Purpose
23
+ This tracker is the operational build control surface for humans and builder agents.
24
+
25
+ ## First file to read
26
+ Read \`docs/ticket-progress.md\` first. Do not scan the full backlog to choose work
27
+ unless the queue is empty or the user explicitly asks for broader planning.
28
+
29
+ ## How to choose work
30
+ Choose the first row in \`LLM_NEXT_QUEUE\` where \`Status\` is \`next\` and \`Blocked By\` is \`None\`.
31
+
32
+ ## Required queue maintenance rule
33
+ Every ticket update must regenerate \`docs/ticket-progress.md\`, including \`LLM_NEXT_QUEUE\`.
34
+ Use \`foreman tickets update <id>\` — never manually edit generated sections.
35
+
36
+ ## Blocked work
37
+ Do not implement blocked rows unless the blocker itself is the approved next work.
38
+
39
+ ## Done policy
40
+ Never mark a ticket \`done\` without required validation evidence or a documented test exception.
41
+ Use \`foreman tickets complete <id> --validation-result passed --evidence "..."\`.
42
+
43
+ ## New work policy
44
+ Newly discovered work belongs in the Discovered Future Work Inbox.
45
+ Use \`foreman tickets discover --summary "..." --rationale "..."\`.
46
+
47
+ ## Generated document policy
48
+ Do not manually edit generated sections of \`docs/ticket-progress.md\`.
49
+ Use the \`foreman tickets\` commands instead.
50
+
51
+ ## STEP_STATUS protocol
52
+ When Foreman is running this project, include the ticket ID in your done marker:
53
+ STEP_STATUS: done | ticket="T001" summary="implemented the feature"
54
+ Use \`foreman tickets discover\` for newly discovered work during a build run.
55
+ `;
56
+ export function renderProgressDoc(input) {
57
+ const { config, projectDir, ticketDefs, states, db } = input;
58
+ const now = input.now ?? DateTime.now().setZone(config.timezone).toISO();
59
+ const queueRows = buildNextQueue(ticketDefs, states, config.queueLimit);
60
+ const activeRows = buildActiveStatusRows(queueRows, ticketDefs, states);
61
+ const futureWork = db.getFutureWork();
62
+ const recentEvents = db.getRecentEvents(config.rendering.maxWorkLogRows);
63
+ const lastSnap = db.getRecentValidationSnapshot();
64
+ const recentCompleted = db.getRecentCompleted();
65
+ const archiveIndex = db.getArchiveIndex();
66
+ const rulesPath = join(projectDir, config.paths.trackerRules);
67
+ const rulesContent = existsSync(rulesPath)
68
+ ? readFileSync(rulesPath, "utf8").trim()
69
+ : DEFAULT_TRACKER_RULES.trim();
70
+ const lines = [];
71
+ // Header
72
+ lines.push("# Ticket Progress Tracker");
73
+ lines.push("");
74
+ if (config.rendering.generatedDocWarning) {
75
+ lines.push("> Generated file. Do not manually edit generated sections. Use `foreman tickets` commands.");
76
+ lines.push("");
77
+ }
78
+ // Tracker Metadata
79
+ lines.push("## Tracker Metadata");
80
+ lines.push(section("TRACKER_METADATA", table(["Field", "Value"], [
81
+ ["App", config.appName],
82
+ ["Queue Limit", String(config.queueLimit)],
83
+ ["Timezone", config.timezone],
84
+ ["Generated At", now],
85
+ ["Remaining Tickets", String(queueRows.length > 0 ? "≥" + queueRows.length : "0")],
86
+ ])));
87
+ lines.push("");
88
+ // Rules
89
+ if (config.rendering.includeRulesInProgressDoc) {
90
+ lines.push(rulesContent);
91
+ lines.push("");
92
+ }
93
+ // LLM_NEXT_QUEUE
94
+ lines.push("## LLM_NEXT_QUEUE");
95
+ const queueTableContent = queueRows.length === 0
96
+ ? "_No remaining tickets. All work is complete or no tickets have been defined._"
97
+ : table(["Rank", "Ticket", "Title", "Status", "Priority", "Area", "Depends On", "Blocked By",
98
+ "Size", "Risk", "Next Action", "Required Tests", "Evidence", "Likely Files"], queueRows.map((r) => [
99
+ String(r.rank), r.ticket, r.title, r.status, r.priority, r.area,
100
+ r.dependsOn, r.blockedBy, r.size, r.risk, r.nextAction,
101
+ r.requiredTests, r.evidence, r.likelyFiles,
102
+ ]));
103
+ lines.push(section("LLM_NEXT_QUEUE", queueTableContent));
104
+ lines.push("");
105
+ // Active Ticket Status
106
+ lines.push("## Active Ticket Status");
107
+ const activeContent = activeRows.length === 0
108
+ ? "_No active tickets._"
109
+ : table(["Ticket", "Title", "Status", "Priority", "Area", "Owner", "last_worked_at",
110
+ "completed_at", "Depends On", "Blockers", "Next Action", "Acceptance / Test Gate",
111
+ "Evidence", "Future Work / Notes"], activeRows.map((r) => [
112
+ r.ticket, r.title, r.status, r.priority, r.area, r.owner,
113
+ r.lastWorkedAt, r.completedAt, r.dependsOn, r.blockers, r.nextAction,
114
+ r.acceptanceTestGate, r.evidence, r.futureWorkNotes,
115
+ ]));
116
+ lines.push(section("ACTIVE_TICKET_STATUS", activeContent));
117
+ lines.push("");
118
+ // Blocked Tickets
119
+ const blockedRows = queueRows.filter((r) => r.blockedBy !== "None");
120
+ lines.push("## Blocked Tickets");
121
+ const blockedContent = blockedRows.length === 0
122
+ ? "_No blocked tickets._"
123
+ : table(["Ticket", "Blocked By", "Blocker Type", "Owner", "First Blocked At",
124
+ "Last Checked At", "Needed Decision / Action", "Unblock Criteria", "Notes"], blockedRows.map((r) => {
125
+ const state = states.get(r.ticket);
126
+ return [
127
+ r.ticket, r.blockedBy,
128
+ state?.blocker_type ?? "dependency",
129
+ state?.owner ?? "unassigned",
130
+ state?.first_blocked_at ?? "N/A",
131
+ state?.last_checked_at ?? "N/A",
132
+ state?.next_action ?? "Resolve blockers",
133
+ state?.blocker_notes ?? "N/A",
134
+ "N/A",
135
+ ];
136
+ }));
137
+ lines.push(section("BLOCKED_TICKETS", blockedContent));
138
+ lines.push("");
139
+ // Recently Completed Context
140
+ lines.push("## Recently Completed Context");
141
+ const recentCompContent = recentCompleted.length === 0
142
+ ? "_No recently completed tickets pinned for context._"
143
+ : table(["Ticket", "Why It Remains Here", "Pinned Until", "Updated At"], recentCompleted.map((r) => [
144
+ r.ticket_id, r.why_it_remains_here, r.pinned_until ?? "N/A", r.updated_at,
145
+ ]));
146
+ lines.push(section("RECENTLY_COMPLETED_CONTEXT", recentCompContent));
147
+ lines.push("");
148
+ // Discovered Future Work Inbox
149
+ lines.push("## Discovered Future Work Inbox");
150
+ const openFutureWork = futureWork.filter((fw) => fw.disposition === "triage");
151
+ const futureContent = openFutureWork.length === 0
152
+ ? "_No discovered future work items._"
153
+ : table(["ID", "Discovered At", "Source Ticket", "Proposed Ticket", "Priority Guess",
154
+ "Area", "Summary", "Rationale", "Needs Decision From", "Disposition"], openFutureWork.map((fw) => [
155
+ String(fw.id ?? ""),
156
+ fw.discovered_at,
157
+ fw.source_ticket ?? "N/A",
158
+ fw.proposed_ticket ?? "N/A",
159
+ fw.priority_guess ?? "N/A",
160
+ fw.area ?? "N/A",
161
+ fw.summary,
162
+ fw.rationale ?? "N/A",
163
+ fw.needs_decision_from ?? "N/A",
164
+ fw.disposition,
165
+ ]));
166
+ lines.push(section("DISCOVERED_FUTURE_WORK", futureContent));
167
+ lines.push("");
168
+ // Archive Index
169
+ lines.push("## Archive Index");
170
+ const archiveContent = archiveIndex.length === 0
171
+ ? "_No archived tickets yet._"
172
+ : table(["Archive File", "Scope", "Last Updated", "Notes"], archiveIndex.map((a) => [
173
+ a.archive_file, a.scope, a.last_updated ?? "N/A", a.notes ?? "N/A",
174
+ ]));
175
+ lines.push(section("ARCHIVE_INDEX", archiveContent));
176
+ lines.push("");
177
+ // Last Validation Snapshot
178
+ lines.push("## Last Validation Snapshot");
179
+ const snapRow = lastSnap
180
+ ? [lastSnap.timestamp, lastSnap.scope, lastSnap.result,
181
+ lastSnap.commands ?? "N/A", lastSnap.evidence ?? "N/A", lastSnap.notes ?? "N/A"]
182
+ : ["N/A", "tracker", "not_run", "N/A", "N/A", "No validation snapshots recorded yet."];
183
+ lines.push(table(["Timestamp", "Scope", "Result", "Commands", "Evidence", "Notes"], [snapRow]));
184
+ lines.push("");
185
+ // Work Log
186
+ lines.push("## Work Log");
187
+ const workLogContent = recentEvents.length === 0
188
+ ? "_No work log entries yet._"
189
+ : table(["Timestamp", "Actor", "Ticket", "Event", "Old Status", "New Status", "Summary"], recentEvents.map((e) => [
190
+ e.timestamp, e.actor ?? "system", e.ticket_id ?? "—",
191
+ e.event_type, e.old_status ?? "—", e.new_status ?? "—", e.summary,
192
+ ]));
193
+ lines.push(section("WORK_LOG", workLogContent));
194
+ lines.push("");
195
+ // Four-Pass Validation Checklist
196
+ lines.push("## Four-Pass Validation Checklist");
197
+ lines.push("");
198
+ lines.push("Run `foreman tickets validate` to execute all passes.");
199
+ lines.push("");
200
+ lines.push("1. **Schema & source** — config.yaml, tickets.yaml schema, unique IDs/orders, valid deps, no cycles.");
201
+ lines.push("2. **State** — SQLite migrations applied, all state ticket_ids exist in tickets.yaml, done tickets have evidence.");
202
+ lines.push("3. **Queue** — correct length, contiguous ranks, no done/canceled, sorted by order, blocked rows marked.");
203
+ lines.push("4. **Generated doc** — required sections and marker comments present, every queued ticket in Active Status.");
204
+ lines.push("");
205
+ return lines.join("\n");
206
+ }
207
+ export function writeProgressDoc(progressDocPath, content, atomic) {
208
+ mkdirSync(dirname(progressDocPath), { recursive: true });
209
+ if (atomic) {
210
+ const tmp = `${progressDocPath}.tmp`;
211
+ writeFileSync(tmp, content, "utf8");
212
+ renameSync(tmp, progressDocPath);
213
+ }
214
+ else {
215
+ writeFileSync(progressDocPath, content, "utf8");
216
+ }
217
+ }
218
+ export function renderAndWrite(input) {
219
+ const { config, projectDir } = input;
220
+ const progressDocPath = join(projectDir, config.paths.progressDoc);
221
+ const content = renderProgressDoc(input);
222
+ writeProgressDoc(progressDocPath, content, config.behavior.useAtomicFileWrites);
223
+ }
@@ -0,0 +1,252 @@
1
+ import Database from "better-sqlite3";
2
+ import { mkdirSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ const INIT_SQL = `
5
+ PRAGMA foreign_keys = ON;
6
+
7
+ CREATE TABLE IF NOT EXISTS ticket_state (
8
+ ticket_id TEXT PRIMARY KEY,
9
+ status TEXT NOT NULL DEFAULT 'planned',
10
+ owner TEXT,
11
+ current_step TEXT,
12
+ next_action TEXT,
13
+ blocked_by_json TEXT NOT NULL DEFAULT '[]',
14
+ blocker_type TEXT,
15
+ blocker_notes TEXT,
16
+ first_blocked_at TEXT,
17
+ last_checked_at TEXT,
18
+ last_worked_at TEXT,
19
+ completed_at TEXT,
20
+ attempt_count INTEGER NOT NULL DEFAULT 0,
21
+ last_error TEXT,
22
+ evidence TEXT,
23
+ validation_result TEXT,
24
+ validation_commands TEXT,
25
+ validation_notes TEXT,
26
+ updated_at TEXT NOT NULL,
27
+ updated_by TEXT,
28
+ CHECK (status IN ('planned','next','in_progress','blocked','done','canceled')),
29
+ CHECK (validation_result IS NULL OR validation_result IN ('passed','failed','not_run','not_applicable'))
30
+ );
31
+
32
+ CREATE TABLE IF NOT EXISTS ticket_events (
33
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
34
+ timestamp TEXT NOT NULL,
35
+ actor TEXT,
36
+ ticket_id TEXT,
37
+ event_type TEXT NOT NULL,
38
+ old_status TEXT,
39
+ new_status TEXT,
40
+ summary TEXT NOT NULL,
41
+ validation TEXT,
42
+ evidence TEXT,
43
+ payload_json TEXT NOT NULL DEFAULT '{}'
44
+ );
45
+
46
+ CREATE TABLE IF NOT EXISTS validation_snapshots (
47
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
48
+ timestamp TEXT NOT NULL,
49
+ scope TEXT NOT NULL,
50
+ result TEXT NOT NULL,
51
+ commands TEXT,
52
+ evidence TEXT,
53
+ notes TEXT,
54
+ CHECK (result IN ('passed','failed','not_run','not_applicable'))
55
+ );
56
+
57
+ CREATE TABLE IF NOT EXISTS future_work (
58
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
59
+ discovered_at TEXT NOT NULL,
60
+ source_ticket TEXT,
61
+ proposed_ticket TEXT,
62
+ priority_guess TEXT,
63
+ area TEXT,
64
+ summary TEXT NOT NULL,
65
+ rationale TEXT,
66
+ needs_decision_from TEXT,
67
+ disposition TEXT NOT NULL DEFAULT 'triage',
68
+ CHECK (disposition IN ('triage','accepted','rejected','merged','queued'))
69
+ );
70
+
71
+ CREATE TABLE IF NOT EXISTS recent_completed_context (
72
+ ticket_id TEXT PRIMARY KEY,
73
+ why_it_remains_here TEXT NOT NULL,
74
+ pinned_until TEXT,
75
+ updated_at TEXT NOT NULL
76
+ );
77
+
78
+ CREATE TABLE IF NOT EXISTS archive_index (
79
+ archive_file TEXT PRIMARY KEY,
80
+ scope TEXT NOT NULL,
81
+ last_updated TEXT,
82
+ notes TEXT
83
+ );
84
+
85
+ CREATE INDEX IF NOT EXISTS idx_ticket_state_status ON ticket_state(status);
86
+ CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket_id ON ticket_events(ticket_id);
87
+ CREATE INDEX IF NOT EXISTS idx_ticket_events_timestamp ON ticket_events(timestamp);
88
+ CREATE INDEX IF NOT EXISTS idx_validation_snapshots_timestamp ON validation_snapshots(timestamp);
89
+ CREATE INDEX IF NOT EXISTS idx_future_work_disposition ON future_work(disposition);
90
+ `;
91
+ export class StateDb {
92
+ db;
93
+ constructor(dbPath) {
94
+ mkdirSync(dirname(dbPath), { recursive: true });
95
+ this.db = new Database(dbPath);
96
+ this.db.pragma("journal_mode = WAL");
97
+ this.db.exec(INIT_SQL);
98
+ }
99
+ close() {
100
+ this.db.close();
101
+ }
102
+ // ── ticket_state ──────────────────────────────────────────────────────────
103
+ getState(ticketId) {
104
+ return this.db
105
+ .prepare("SELECT * FROM ticket_state WHERE ticket_id = ?")
106
+ .get(ticketId);
107
+ }
108
+ getAllStates() {
109
+ const rows = this.db.prepare("SELECT * FROM ticket_state").all();
110
+ const m = new Map();
111
+ for (const row of rows)
112
+ m.set(row.ticket_id, row);
113
+ return m;
114
+ }
115
+ upsertState(ticketId, patch, now) {
116
+ const existing = this.getState(ticketId);
117
+ const base = existing ?? {
118
+ ticket_id: ticketId,
119
+ status: "planned",
120
+ owner: null,
121
+ current_step: null,
122
+ next_action: null,
123
+ blocked_by_json: "[]",
124
+ blocker_type: null,
125
+ blocker_notes: null,
126
+ first_blocked_at: null,
127
+ last_checked_at: null,
128
+ last_worked_at: null,
129
+ completed_at: null,
130
+ attempt_count: 0,
131
+ last_error: null,
132
+ evidence: null,
133
+ validation_result: null,
134
+ validation_commands: null,
135
+ validation_notes: null,
136
+ updated_at: now,
137
+ updated_by: null,
138
+ };
139
+ const merged = { ...base, ...patch, ticket_id: ticketId, updated_at: now };
140
+ if (existing) {
141
+ this.db.prepare(`
142
+ UPDATE ticket_state SET
143
+ status=@status, owner=@owner, current_step=@current_step,
144
+ next_action=@next_action, blocked_by_json=@blocked_by_json,
145
+ blocker_type=@blocker_type, blocker_notes=@blocker_notes,
146
+ first_blocked_at=@first_blocked_at, last_checked_at=@last_checked_at,
147
+ last_worked_at=@last_worked_at, completed_at=@completed_at,
148
+ attempt_count=@attempt_count, last_error=@last_error,
149
+ evidence=@evidence, validation_result=@validation_result,
150
+ validation_commands=@validation_commands, validation_notes=@validation_notes,
151
+ updated_at=@updated_at, updated_by=@updated_by
152
+ WHERE ticket_id=@ticket_id
153
+ `).run(merged);
154
+ }
155
+ else {
156
+ this.db.prepare(`
157
+ INSERT INTO ticket_state (
158
+ ticket_id,status,owner,current_step,next_action,blocked_by_json,
159
+ blocker_type,blocker_notes,first_blocked_at,last_checked_at,
160
+ last_worked_at,completed_at,attempt_count,last_error,evidence,
161
+ validation_result,validation_commands,validation_notes,updated_at,updated_by
162
+ ) VALUES (
163
+ @ticket_id,@status,@owner,@current_step,@next_action,@blocked_by_json,
164
+ @blocker_type,@blocker_notes,@first_blocked_at,@last_checked_at,
165
+ @last_worked_at,@completed_at,@attempt_count,@last_error,@evidence,
166
+ @validation_result,@validation_commands,@validation_notes,@updated_at,@updated_by
167
+ )
168
+ `).run(merged);
169
+ }
170
+ }
171
+ // ── ticket_events ─────────────────────────────────────────────────────────
172
+ insertEvent(event) {
173
+ this.db.prepare(`
174
+ INSERT INTO ticket_events (
175
+ timestamp,actor,ticket_id,event_type,old_status,new_status,
176
+ summary,validation,evidence,payload_json
177
+ ) VALUES (
178
+ @timestamp,@actor,@ticket_id,@event_type,@old_status,@new_status,
179
+ @summary,@validation,@evidence,@payload_json
180
+ )
181
+ `).run(event);
182
+ }
183
+ getRecentEvents(limit) {
184
+ return this.db
185
+ .prepare("SELECT * FROM ticket_events ORDER BY timestamp DESC, id DESC LIMIT ?")
186
+ .all(limit);
187
+ }
188
+ // ── validation_snapshots ──────────────────────────────────────────────────
189
+ insertValidationSnapshot(snap) {
190
+ this.db.prepare(`
191
+ INSERT INTO validation_snapshots (timestamp,scope,result,commands,evidence,notes)
192
+ VALUES (@timestamp,@scope,@result,@commands,@evidence,@notes)
193
+ `).run(snap);
194
+ }
195
+ getRecentValidationSnapshot() {
196
+ return this.db
197
+ .prepare("SELECT * FROM validation_snapshots ORDER BY timestamp DESC, id DESC LIMIT 1")
198
+ .get();
199
+ }
200
+ // ── future_work ───────────────────────────────────────────────────────────
201
+ insertFutureWork(item) {
202
+ const r = this.db.prepare(`
203
+ INSERT INTO future_work (
204
+ discovered_at,source_ticket,proposed_ticket,priority_guess,
205
+ area,summary,rationale,needs_decision_from,disposition
206
+ ) VALUES (
207
+ @discovered_at,@source_ticket,@proposed_ticket,@priority_guess,
208
+ @area,@summary,@rationale,@needs_decision_from,@disposition
209
+ )
210
+ `).run(item);
211
+ return r.lastInsertRowid;
212
+ }
213
+ getFutureWork() {
214
+ return this.db
215
+ .prepare("SELECT * FROM future_work ORDER BY id")
216
+ .all();
217
+ }
218
+ getFutureWorkById(id) {
219
+ return this.db
220
+ .prepare("SELECT * FROM future_work WHERE id = ?")
221
+ .get(id);
222
+ }
223
+ updateFutureWorkDisposition(id, disposition) {
224
+ this.db.prepare("UPDATE future_work SET disposition = ? WHERE id = ?").run(disposition, id);
225
+ }
226
+ // ── recent_completed_context ──────────────────────────────────────────────
227
+ getRecentCompleted() {
228
+ return this.db
229
+ .prepare("SELECT * FROM recent_completed_context ORDER BY updated_at DESC")
230
+ .all();
231
+ }
232
+ upsertRecentCompleted(row) {
233
+ this.db.prepare(`
234
+ INSERT INTO recent_completed_context (ticket_id,why_it_remains_here,pinned_until,updated_at)
235
+ VALUES (@ticket_id,@why_it_remains_here,@pinned_until,@updated_at)
236
+ ON CONFLICT(ticket_id) DO UPDATE SET
237
+ why_it_remains_here=excluded.why_it_remains_here,
238
+ pinned_until=excluded.pinned_until,
239
+ updated_at=excluded.updated_at
240
+ `).run(row);
241
+ }
242
+ // ── archive_index ─────────────────────────────────────────────────────────
243
+ getArchiveIndex() {
244
+ return this.db
245
+ .prepare("SELECT * FROM archive_index ORDER BY last_updated DESC")
246
+ .all();
247
+ }
248
+ // ── transactions ──────────────────────────────────────────────────────────
249
+ transaction(fn) {
250
+ return this.db.transaction(fn)();
251
+ }
252
+ }
@@ -0,0 +1,84 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { parse, stringify } from "yaml";
3
+ import { Ajv } from "ajv";
4
+ import { TICKET_JSON_SCHEMA } from "./ticketSchema.js";
5
+ const ajv = new Ajv({ allErrors: true });
6
+ const validateTicketSchema = ajv.compile(TICKET_JSON_SCHEMA);
7
+ export function loadTickets(ticketsPath) {
8
+ if (!existsSync(ticketsPath))
9
+ return [];
10
+ const raw = parse(readFileSync(ticketsPath, "utf8"));
11
+ if (!raw || !Array.isArray(raw.tickets))
12
+ return [];
13
+ return raw.tickets;
14
+ }
15
+ export function saveTickets(ticketsPath, tickets) {
16
+ writeFileSync(ticketsPath, stringify({ tickets }, { lineWidth: 120 }), "utf8");
17
+ }
18
+ export function validateTicketDefs(tickets) {
19
+ const errors = [];
20
+ for (const ticket of tickets) {
21
+ if (!validateTicketSchema(ticket)) {
22
+ for (const err of validateTicketSchema.errors ?? []) {
23
+ errors.push({
24
+ path: `tickets[${ticket.id ?? "?"}]${err.instancePath}`,
25
+ message: err.message ?? "unknown error",
26
+ });
27
+ }
28
+ }
29
+ }
30
+ const ids = new Set();
31
+ for (const t of tickets) {
32
+ if (ids.has(t.id)) {
33
+ errors.push({ path: `tickets[${t.id}]`, message: `duplicate ticket ID: ${t.id}` });
34
+ }
35
+ ids.add(t.id);
36
+ }
37
+ const orders = new Set();
38
+ for (const t of tickets) {
39
+ if (orders.has(t.order)) {
40
+ errors.push({ path: `tickets[${t.id}]`, message: `duplicate order value: ${t.order}` });
41
+ }
42
+ orders.add(t.order);
43
+ }
44
+ for (const t of tickets) {
45
+ for (const dep of t.depends_on) {
46
+ if (!ids.has(dep)) {
47
+ errors.push({ path: `tickets[${t.id}].depends_on`, message: `references unknown ticket: ${dep}` });
48
+ }
49
+ }
50
+ }
51
+ for (const t of tickets) {
52
+ if ((t.risk === "Medium" || t.risk === "High") && !t.rollback) {
53
+ errors.push({ path: `tickets[${t.id}]`, message: `${t.risk} risk ticket requires rollback/mitigation notes` });
54
+ }
55
+ }
56
+ return errors;
57
+ }
58
+ export function detectCycles(tickets) {
59
+ const adj = new Map();
60
+ for (const t of tickets)
61
+ adj.set(t.id, t.depends_on);
62
+ const visited = new Set();
63
+ const inStack = new Set();
64
+ const cycles = [];
65
+ function dfs(id, path) {
66
+ if (inStack.has(id)) {
67
+ const cycleStart = path.indexOf(id);
68
+ cycles.push([...path.slice(cycleStart), id].join(" → "));
69
+ return;
70
+ }
71
+ if (visited.has(id))
72
+ return;
73
+ visited.add(id);
74
+ inStack.add(id);
75
+ for (const dep of adj.get(id) ?? [])
76
+ dfs(dep, [...path, id]);
77
+ inStack.delete(id);
78
+ }
79
+ for (const t of tickets) {
80
+ if (!visited.has(t.id))
81
+ dfs(t.id, []);
82
+ }
83
+ return cycles;
84
+ }
@@ -0,0 +1,31 @@
1
+ export const TICKET_JSON_SCHEMA = {
2
+ type: "object",
3
+ required: [
4
+ "id", "order", "title", "area", "priority", "size", "risk",
5
+ "depends_on", "summary", "acceptance", "required_tests", "likely_files",
6
+ ],
7
+ additionalProperties: true,
8
+ properties: {
9
+ id: { type: "string", pattern: "^[A-Za-z][A-Za-z0-9-_]*$" },
10
+ order: { type: "number", minimum: 0 },
11
+ title: { type: "string", minLength: 1 },
12
+ area: { type: "string", minLength: 1 },
13
+ priority: { type: "string", enum: ["P0", "P1", "P2", "P3"] },
14
+ size: { type: "string", enum: ["XS", "S", "M", "L", "XL"] },
15
+ risk: { type: "string", enum: ["Low", "Medium", "High"] },
16
+ depends_on: { type: "array", items: { type: "string" } },
17
+ summary: { type: "string", minLength: 1 },
18
+ acceptance: { type: "array", items: { type: "string" }, minItems: 1 },
19
+ required_tests: { type: "array", items: { type: "string" }, minItems: 1 },
20
+ likely_files: { type: "array", items: { type: "string" } },
21
+ rollback: { type: ["string", "null"] },
22
+ notes: { type: ["string", "null"] },
23
+ },
24
+ };
25
+ export const TICKETS_FILE_SCHEMA = {
26
+ type: "object",
27
+ required: ["tickets"],
28
+ properties: {
29
+ tickets: { type: "array", items: TICKET_JSON_SCHEMA },
30
+ },
31
+ };
@@ -0,0 +1,74 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { validateTicketDefs, detectCycles } from "./ticketLoader.js";
4
+ import { buildNextQueue } from "./queue.js";
5
+ export function runAllValidation(config, projectDir, ticketDefs, states, db) {
6
+ const issues = [];
7
+ // Pass 1: schema and source
8
+ for (const err of validateTicketDefs(ticketDefs)) {
9
+ issues.push({ pass: 1, severity: "error", message: `[schema] ${err.path}: ${err.message}` });
10
+ }
11
+ for (const cycle of detectCycles(ticketDefs)) {
12
+ issues.push({ pass: 1, severity: "error", message: `[cycle] dependency cycle: ${cycle}` });
13
+ }
14
+ // Pass 2: state validation
15
+ const ticketIds = new Set(ticketDefs.map((t) => t.id));
16
+ for (const [id, state] of states) {
17
+ if (!ticketIds.has(id)) {
18
+ issues.push({ pass: 2, severity: "error", message: `[state] ticket_state row for unknown ticket: ${id}` });
19
+ }
20
+ if (state.status === "done" && !state.completed_at) {
21
+ issues.push({ pass: 2, severity: "error", message: `[state] done ticket ${id} missing completed_at` });
22
+ }
23
+ if (state.status === "done" && !state.evidence && config.behavior.requireValidationEvidenceForDone) {
24
+ issues.push({ pass: 2, severity: "warn", message: `[state] done ticket ${id} has no evidence` });
25
+ }
26
+ }
27
+ // Pass 3: queue validation
28
+ const queueRows = buildNextQueue(ticketDefs, states, config.queueLimit);
29
+ const remaining = ticketDefs.filter((t) => {
30
+ const s = states.get(t.id);
31
+ return s?.status !== "done" && s?.status !== "canceled";
32
+ });
33
+ const expectedLen = Math.min(config.queueLimit, remaining.length);
34
+ if (queueRows.length !== expectedLen) {
35
+ issues.push({ pass: 3, severity: "error", message: `[queue] expected ${expectedLen} rows, got ${queueRows.length}` });
36
+ }
37
+ for (let i = 0; i < queueRows.length; i++) {
38
+ if (queueRows[i].rank !== i + 1) {
39
+ issues.push({ pass: 3, severity: "error", message: `[queue] rank gap at position ${i + 1}: got ${queueRows[i].rank}` });
40
+ }
41
+ }
42
+ const blockedWithNone = queueRows.filter((r) => r.status === "blocked" && r.blockedBy === "None");
43
+ for (const r of blockedWithNone) {
44
+ issues.push({ pass: 3, severity: "error", message: `[queue] ${r.ticket} is blocked but Blocked By is None` });
45
+ }
46
+ // Pass 4: generated Markdown doc
47
+ const progressDocPath = join(projectDir, config.paths.progressDoc);
48
+ if (!existsSync(progressDocPath)) {
49
+ issues.push({ pass: 4, severity: "error", message: "[doc] docs/ticket-progress.md does not exist; run `foreman tickets render`" });
50
+ }
51
+ else {
52
+ const doc = readFileSync(progressDocPath, "utf8");
53
+ const requiredMarkers = [
54
+ "LLM_NEXT_QUEUE_START", "LLM_NEXT_QUEUE_END",
55
+ "ACTIVE_TICKET_STATUS_START", "ACTIVE_TICKET_STATUS_END",
56
+ "BLOCKED_TICKETS_START", "BLOCKED_TICKETS_END",
57
+ "WORK_LOG_START", "WORK_LOG_END",
58
+ ];
59
+ for (const marker of requiredMarkers) {
60
+ if (!doc.includes(`<!-- ${marker} -->`)) {
61
+ issues.push({ pass: 4, severity: "error", message: `[doc] missing marker: <!-- ${marker} -->` });
62
+ }
63
+ }
64
+ if (!doc.includes("## LLM_NEXT_QUEUE")) {
65
+ issues.push({ pass: 4, severity: "error", message: "[doc] missing ## LLM_NEXT_QUEUE section" });
66
+ }
67
+ }
68
+ return issues;
69
+ }
70
+ export function formatValidationIssues(issues) {
71
+ if (issues.length === 0)
72
+ return "All validation passes clean.";
73
+ return issues.map((i) => ` [pass ${i.pass}] [${i.severity}] ${i.message}`).join("\n");
74
+ }