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,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
|
+
}
|