ai-foreman 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,183 @@
1
+ import { loadTickets, saveTickets, validateTicketDefs } from "./ticketLoader.js";
2
+ import { loadTicketsConfig, resolveTicketPaths } from "./config.js";
3
+ import { StateDb } from "./stateDb.js";
4
+ import { nowTimestamp } from "./events.js";
5
+ import { renderAndWrite } from "./renderMarkdown.js";
6
+ export function createReviewRecommendation(db, now, input) {
7
+ return db.insertReviewRecommendation({
8
+ created_at: now,
9
+ kind: input.kind,
10
+ status: "pending",
11
+ summary: input.summary,
12
+ rationale: input.rationale ?? null,
13
+ ticket_ids_json: JSON.stringify(input.ticketIds ?? []),
14
+ patch_json: JSON.stringify(input.patch),
15
+ source_json: JSON.stringify(input.source ?? {}),
16
+ updated_at: null,
17
+ });
18
+ }
19
+ export function cmdReviewRecommendations(projectDir, opts = {}) {
20
+ const config = loadTicketsConfig(projectDir);
21
+ const paths = resolveTicketPaths(config, projectDir);
22
+ const db = new StateDb(paths.stateDb);
23
+ try {
24
+ const pending = db.getReviewRecommendations("pending");
25
+ if (!opts.action)
26
+ return { pending, changed: 0 };
27
+ const selected = opts.all
28
+ ? pending
29
+ : (opts.ids ?? []).map((id) => {
30
+ const row = db.getReviewRecommendation(id);
31
+ if (!row)
32
+ throw new Error(`review recommendation ${id} not found`);
33
+ if (row.status !== "pending")
34
+ throw new Error(`review recommendation ${id} is ${row.status}, not pending`);
35
+ return row;
36
+ });
37
+ if (selected.length === 0)
38
+ return { pending, changed: 0, action: opts.action };
39
+ const now = nowTimestamp(config.timezone);
40
+ if (opts.action === "accept") {
41
+ let tickets = loadTickets(paths.tickets);
42
+ for (const rec of selected) {
43
+ tickets = applyRecommendationPatch(tickets, parseRecommendationPatch(rec));
44
+ }
45
+ const issues = validateTicketDefs(tickets);
46
+ if (issues.length > 0) {
47
+ throw new Error(`accepted recommendation produced invalid tickets:\n${issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n")}`);
48
+ }
49
+ saveTickets(paths.tickets, tickets);
50
+ db.transaction(() => {
51
+ for (const rec of selected)
52
+ db.updateReviewRecommendationStatus(rec.id, "accepted", now);
53
+ });
54
+ const states = db.getAllStates();
55
+ renderAndWrite({ config, projectDir, ticketDefs: tickets, states, db });
56
+ }
57
+ else {
58
+ const status = opts.action === "defer" ? "deferred" : "dismissed";
59
+ db.transaction(() => {
60
+ for (const rec of selected)
61
+ db.updateReviewRecommendationStatus(rec.id, status, now);
62
+ });
63
+ const tickets = loadTickets(paths.tickets);
64
+ const states = db.getAllStates();
65
+ renderAndWrite({ config, projectDir, ticketDefs: tickets, states, db });
66
+ }
67
+ return { pending: db.getReviewRecommendations("pending"), changed: selected.length, action: opts.action };
68
+ }
69
+ finally {
70
+ db.close();
71
+ }
72
+ }
73
+ export function applyRecommendationPatch(tickets, patch) {
74
+ const byId = new Map(tickets.map((ticket) => [ticket.id, { ...ticket }]));
75
+ for (const id of patch.remove ?? []) {
76
+ if (!byId.has(id))
77
+ throw new Error(`patch cannot remove unknown ticket ${id}`);
78
+ byId.delete(id);
79
+ }
80
+ for (const update of patch.update ?? []) {
81
+ const current = byId.get(update.id);
82
+ if (!current)
83
+ throw new Error(`patch cannot update unknown ticket ${update.id}`);
84
+ byId.set(update.id, {
85
+ ...current,
86
+ ...update.set,
87
+ id: current.id,
88
+ order: update.set.order ?? current.order,
89
+ });
90
+ }
91
+ for (const ticket of patch.add ?? []) {
92
+ if (byId.has(ticket.id))
93
+ throw new Error(`patch cannot add duplicate ticket ${ticket.id}`);
94
+ byId.set(ticket.id, { ...ticket });
95
+ }
96
+ return Array.from(byId.values()).sort((a, b) => a.order - b.order || a.id.localeCompare(b.id));
97
+ }
98
+ export function insertXlSplitRecommendations(projectDir, tickets) {
99
+ const config = loadTicketsConfig(projectDir);
100
+ const paths = resolveTicketPaths(config, projectDir);
101
+ const db = new StateDb(paths.stateDb);
102
+ try {
103
+ const now = nowTimestamp(config.timezone);
104
+ const existing = db.getReviewRecommendations().filter((rec) => {
105
+ const ids = parseStringArray(rec.ticket_ids_json);
106
+ return rec.kind === "split" && ids.length === 1;
107
+ });
108
+ let count = 0;
109
+ let nextNumber = nextTicketNumber(tickets);
110
+ const maxOrder = tickets.reduce((highest, ticket) => Math.max(highest, ticket.order), 0);
111
+ for (const ticket of tickets) {
112
+ if (ticket.size !== "XL")
113
+ continue;
114
+ if (existing.some((rec) => parseStringArray(rec.ticket_ids_json)[0] === ticket.id && rec.status !== "dismissed"))
115
+ continue;
116
+ const firstId = `T${String(nextNumber++).padStart(3, "0")}`;
117
+ const secondId = `T${String(nextNumber++).padStart(3, "0")}`;
118
+ createReviewRecommendation(db, now, {
119
+ kind: "split",
120
+ summary: `Split XL ticket ${ticket.id}: ${ticket.title}`,
121
+ rationale: "Imported ticket is XL-sized; smaller implementation slices are safer for branch-per-ticket execution.",
122
+ ticketIds: [ticket.id],
123
+ patch: {
124
+ remove: [ticket.id],
125
+ add: [
126
+ {
127
+ ...ticket,
128
+ id: firstId,
129
+ order: maxOrder + count * 2000 + 1000,
130
+ title: `${ticket.title} - implementation slice`,
131
+ size: "L",
132
+ depends_on: ticket.depends_on,
133
+ notes: appendNote(ticket.notes, `Split from ${ticket.id}.`),
134
+ },
135
+ {
136
+ ...ticket,
137
+ id: secondId,
138
+ order: maxOrder + count * 2000 + 2000,
139
+ title: `${ticket.title} - validation slice`,
140
+ size: "M",
141
+ depends_on: [firstId],
142
+ notes: appendNote(ticket.notes, `Split from ${ticket.id}.`),
143
+ },
144
+ ],
145
+ },
146
+ source: { generated_by: "import", original_ticket: ticket.id },
147
+ });
148
+ count++;
149
+ }
150
+ return count;
151
+ }
152
+ finally {
153
+ db.close();
154
+ }
155
+ }
156
+ function parseRecommendationPatch(rec) {
157
+ const parsed = JSON.parse(rec.patch_json);
158
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
159
+ throw new Error(`review recommendation ${rec.id} has invalid patch_json`);
160
+ }
161
+ return parsed;
162
+ }
163
+ function parseStringArray(json) {
164
+ try {
165
+ const parsed = JSON.parse(json);
166
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
167
+ }
168
+ catch {
169
+ return [];
170
+ }
171
+ }
172
+ function nextTicketNumber(tickets) {
173
+ let max = 0;
174
+ for (const ticket of tickets) {
175
+ const match = /^T(\d+)$/i.exec(ticket.id);
176
+ if (match)
177
+ max = Math.max(max, Number.parseInt(match[1], 10));
178
+ }
179
+ return max + 1;
180
+ }
181
+ function appendNote(current, note) {
182
+ return current?.trim() ? `${current.trim()}\n${note}` : note;
183
+ }
@@ -59,13 +59,14 @@ export const DEFAULT_TRACKER_RULES = renderTrackerRules();
59
59
  export function renderProgressDoc(input) {
60
60
  const { config, projectDir, ticketDefs, states, db } = input;
61
61
  const now = input.now ?? DateTime.now().setZone(config.timezone).toISO();
62
- const queueRows = buildNextQueue(ticketDefs, states, config.queueLimit);
62
+ const queueRows = buildNextQueue(ticketDefs, states, config.implementationLimit);
63
63
  const activeRows = buildActiveStatusRows(queueRows, ticketDefs, states);
64
64
  const futureWork = db.getFutureWork();
65
65
  const recentEvents = db.getRecentEvents(config.rendering.maxWorkLogRows);
66
66
  const lastSnap = db.getRecentValidationSnapshot();
67
67
  const recentCompleted = db.getRecentCompleted();
68
68
  const archiveIndex = db.getArchiveIndex();
69
+ const pendingRecommendations = db.getReviewRecommendations("pending");
69
70
  const rulesPath = join(projectDir, config.paths.trackerRules);
70
71
  const rulesContent = existsSync(rulesPath)
71
72
  ? readFileSync(rulesPath, "utf8").trim()
@@ -82,7 +83,8 @@ export function renderProgressDoc(input) {
82
83
  lines.push("## Tracker Metadata");
83
84
  lines.push(section("TRACKER_METADATA", table(["Field", "Value"], [
84
85
  ["App", config.appName],
85
- ["Queue Limit", String(config.queueLimit)],
86
+ ["Implementation Limit", String(config.implementationLimit)],
87
+ ["View Limit", String(config.viewLimit)],
86
88
  ["Timezone", config.timezone],
87
89
  ["Generated At", now],
88
90
  ["Remaining Tickets", String(queueRows.length > 0 ? "≥" + queueRows.length : "0")],
@@ -168,6 +170,19 @@ export function renderProgressDoc(input) {
168
170
  ]));
169
171
  lines.push(section("DISCOVERED_FUTURE_WORK", futureContent));
170
172
  lines.push("");
173
+ // Review Recommendations
174
+ lines.push("## Review Recommendations");
175
+ const recommendationContent = pendingRecommendations.length === 0
176
+ ? "_No pending review recommendations._"
177
+ : table(["ID", "Kind", "Tickets", "Summary", "Rationale"], pendingRecommendations.map((rec) => [
178
+ String(rec.id ?? ""),
179
+ rec.kind,
180
+ parseTicketIds(rec.ticket_ids_json).join(", ") || "N/A",
181
+ rec.summary,
182
+ rec.rationale ?? "N/A",
183
+ ]));
184
+ lines.push(section("REVIEW_RECOMMENDATIONS", recommendationContent));
185
+ lines.push("");
171
186
  // Archive Index
172
187
  lines.push("## Archive Index");
173
188
  const archiveContent = archiveIndex.length === 0
@@ -207,6 +222,15 @@ export function renderProgressDoc(input) {
207
222
  lines.push("");
208
223
  return lines.join("\n");
209
224
  }
225
+ function parseTicketIds(json) {
226
+ try {
227
+ const parsed = JSON.parse(json);
228
+ return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : [];
229
+ }
230
+ catch {
231
+ return [];
232
+ }
233
+ }
210
234
  export function writeProgressDoc(progressDocPath, content, atomic) {
211
235
  mkdirSync(dirname(progressDocPath), { recursive: true });
212
236
  if (atomic) {
@@ -0,0 +1,77 @@
1
+ export declare const RAFI_CONFIG_FILE = "rafi-config.yaml";
2
+ export type HarnessTarget = "claude" | "codex";
3
+ export type TicketBuildCompletionMode = "pr" | "auto-merge" | "direct-merge" | "none";
4
+ export type TicketBuildProvider = "auto" | "github" | "gitlab" | "local";
5
+ export type TicketBuildMergeMethod = "squash" | "merge" | "rebase";
6
+ export type TicketBuildBranchStrategy = "branch-per-ticket" | "batch";
7
+ export type TicketPopulateAgentPreference = "configured" | "claude" | "codex";
8
+ export type TicketPopulateEnrichmentPolicy = "none" | "recommendations" | "agent";
9
+ export type TicketSourceConfig = {
10
+ type: "local";
11
+ paths: string[];
12
+ } | {
13
+ type: "linear";
14
+ api_key_env: string;
15
+ team_key?: string | null;
16
+ filter?: string | null;
17
+ } | {
18
+ type: "jira";
19
+ site: string;
20
+ email_env: string;
21
+ token_env: string;
22
+ jql: string;
23
+ };
24
+ export interface TicketPopulateSetupConfig {
25
+ source_handling: "saved" | "prompt" | "manual";
26
+ agent_preference: TicketPopulateAgentPreference;
27
+ import_cap: number;
28
+ comment_limit: number;
29
+ enrichment: TicketPopulateEnrichmentPolicy;
30
+ recommend_split_for_xl: boolean;
31
+ }
32
+ export interface TicketBuildSetupConfig {
33
+ branch_strategy: TicketBuildBranchStrategy;
34
+ completion: TicketBuildCompletionMode;
35
+ provider: TicketBuildProvider;
36
+ pr_ready: boolean;
37
+ merge_method: TicketBuildMergeMethod;
38
+ cleanup: boolean;
39
+ auto_merge_wait: boolean;
40
+ auto_merge_timeout_minutes: number | null;
41
+ }
42
+ export interface TicketsSetupConfig {
43
+ sources: TicketSourceConfig[];
44
+ populate: TicketPopulateSetupConfig;
45
+ build: TicketBuildSetupConfig;
46
+ }
47
+ export interface MinimalRafiConfigOptions {
48
+ appName?: string;
49
+ timezone?: string;
50
+ docsRoot?: string;
51
+ targets?: HarnessTarget[];
52
+ }
53
+ export declare const DEFAULT_TICKET_SETUP: TicketsSetupConfig;
54
+ export declare function loadRafiConfigObject(projectDir: string): Record<string, unknown> | undefined;
55
+ export declare function loadTicketSetupConfig(projectDir: string): TicketsSetupConfig | undefined;
56
+ export declare function loadTicketSetupConfigWithDefaults(projectDir: string): TicketsSetupConfig;
57
+ export declare function hasTicketSetupConfig(projectDir: string): boolean;
58
+ export declare function saveTicketSetupConfig(projectDir: string, setup: TicketsSetupConfig, opts?: MinimalRafiConfigOptions): void;
59
+ export declare function ensureRafiConfigForTicketSetup(projectDir: string, opts?: MinimalRafiConfigOptions): Record<string, unknown>;
60
+ export declare function normalizeTicketsSetupConfig(value: unknown, label?: string): TicketsSetupConfig;
61
+ export declare function denormalizeTicketsSetupConfig(setup: TicketsSetupConfig): Record<string, unknown>;
62
+ export declare function mergeTicketSetup(current: TicketsSetupConfig | undefined, patch: Partial<{
63
+ sources: TicketSourceConfig[];
64
+ populate: Partial<TicketPopulateSetupConfig>;
65
+ build: Partial<TicketBuildSetupConfig>;
66
+ }>): TicketsSetupConfig;
67
+ export declare function localSourcePaths(setup: TicketsSetupConfig | undefined): string[];
68
+ export declare function externalSources(setup: TicketsSetupConfig | undefined): Extract<TicketSourceConfig, {
69
+ type: "linear" | "jira";
70
+ }>[];
71
+ export declare function configuredDocsRoot(projectDir: string): string | undefined;
72
+ export declare function configuredAppName(projectDir: string): string | undefined;
73
+ export declare function configuredHarnessTargets(projectDir: string): HarnessTarget[] | undefined;
74
+ export declare function detectPackageName(projectDir: string): string | undefined;
75
+ export declare function defaultAppName(projectDir: string): string;
76
+ export declare function detectGitProvider(projectDir: string): Exclude<TicketBuildProvider, "auto" | "local"> | undefined;
77
+ export declare function recommendedBuildDefaults(projectDir: string): TicketBuildSetupConfig;
@@ -0,0 +1,369 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parse, stringify } from "yaml";
5
+ import { loadDefaults } from "special-agents";
6
+ export const RAFI_CONFIG_FILE = "rafi-config.yaml";
7
+ const AGENT_NAMES = ["builder", "qa", "planner", "ticket-maker"];
8
+ const SKILL_NAMES = [
9
+ "better-sqlite3-rebuild",
10
+ "grill-me",
11
+ "improve-codebase-architecture",
12
+ "prd-to-issues",
13
+ "tdd",
14
+ "write-a-prd",
15
+ ];
16
+ export const DEFAULT_TICKET_SETUP = {
17
+ sources: [],
18
+ populate: {
19
+ source_handling: "saved",
20
+ agent_preference: "configured",
21
+ import_cap: 500,
22
+ comment_limit: 10,
23
+ enrichment: "recommendations",
24
+ recommend_split_for_xl: true,
25
+ },
26
+ build: {
27
+ branch_strategy: "branch-per-ticket",
28
+ completion: "none",
29
+ provider: "auto",
30
+ pr_ready: false,
31
+ merge_method: "squash",
32
+ cleanup: true,
33
+ auto_merge_wait: false,
34
+ auto_merge_timeout_minutes: null,
35
+ },
36
+ };
37
+ export function loadRafiConfigObject(projectDir) {
38
+ const configPath = join(projectDir, RAFI_CONFIG_FILE);
39
+ if (!existsSync(configPath))
40
+ return undefined;
41
+ const raw = parse(readFileSync(configPath, "utf8"));
42
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
43
+ throw new Error(`${RAFI_CONFIG_FILE}: expected a YAML object`);
44
+ }
45
+ return raw;
46
+ }
47
+ export function loadTicketSetupConfig(projectDir) {
48
+ const raw = loadRafiConfigObject(projectDir);
49
+ if (!raw || raw.tickets === undefined)
50
+ return undefined;
51
+ return normalizeTicketsSetupConfig(raw.tickets, `${RAFI_CONFIG_FILE}.tickets`);
52
+ }
53
+ export function loadTicketSetupConfigWithDefaults(projectDir) {
54
+ return loadTicketSetupConfig(projectDir) ?? cloneTicketSetup(DEFAULT_TICKET_SETUP);
55
+ }
56
+ export function hasTicketSetupConfig(projectDir) {
57
+ const raw = loadRafiConfigObject(projectDir);
58
+ return Boolean(raw?.tickets);
59
+ }
60
+ export function saveTicketSetupConfig(projectDir, setup, opts = {}) {
61
+ const config = loadRafiConfigObject(projectDir) ?? minimalRafiConfig(projectDir, opts);
62
+ config.tickets = denormalizeTicketsSetupConfig(normalizeTicketsSetupConfig(setup, "tickets"));
63
+ mkdirSync(projectDir, { recursive: true });
64
+ writeFileSync(join(projectDir, RAFI_CONFIG_FILE), stringify(config, { lineWidth: 100 }), "utf8");
65
+ }
66
+ export function ensureRafiConfigForTicketSetup(projectDir, opts = {}) {
67
+ const existing = loadRafiConfigObject(projectDir);
68
+ if (existing)
69
+ return existing;
70
+ const config = minimalRafiConfig(projectDir, opts);
71
+ mkdirSync(projectDir, { recursive: true });
72
+ writeFileSync(join(projectDir, RAFI_CONFIG_FILE), stringify(config, { lineWidth: 100 }), "utf8");
73
+ return config;
74
+ }
75
+ export function normalizeTicketsSetupConfig(value, label = "tickets") {
76
+ if (value === undefined || value === null)
77
+ return cloneTicketSetup(DEFAULT_TICKET_SETUP);
78
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
79
+ throw new Error(`${label}: expected an object`);
80
+ }
81
+ const raw = value;
82
+ const setup = {
83
+ sources: normalizeSources(raw.sources, `${label}.sources`),
84
+ populate: normalizePopulate(raw.populate, `${label}.populate`),
85
+ build: normalizeBuild(raw.build, `${label}.build`),
86
+ };
87
+ return setup;
88
+ }
89
+ export function denormalizeTicketsSetupConfig(setup) {
90
+ return {
91
+ sources: setup.sources.map((source) => ({ ...source })),
92
+ populate: { ...setup.populate },
93
+ build: { ...setup.build },
94
+ };
95
+ }
96
+ export function mergeTicketSetup(current, patch) {
97
+ const base = current ? cloneTicketSetup(current) : cloneTicketSetup(DEFAULT_TICKET_SETUP);
98
+ return normalizeTicketsSetupConfig({
99
+ sources: patch.sources ?? base.sources,
100
+ populate: { ...base.populate, ...(patch.populate ?? {}) },
101
+ build: { ...base.build, ...(patch.build ?? {}) },
102
+ });
103
+ }
104
+ export function localSourcePaths(setup) {
105
+ return unique((setup?.sources ?? [])
106
+ .filter((source) => source.type === "local")
107
+ .flatMap((source) => source.paths));
108
+ }
109
+ export function externalSources(setup) {
110
+ return (setup?.sources ?? []).filter((source) => source.type === "linear" || source.type === "jira");
111
+ }
112
+ export function configuredDocsRoot(projectDir) {
113
+ const raw = loadRafiConfigObject(projectDir);
114
+ const docs = raw?.docs;
115
+ return typeof docs?.root === "string" ? docs.root : undefined;
116
+ }
117
+ export function configuredAppName(projectDir) {
118
+ const raw = loadRafiConfigObject(projectDir);
119
+ return typeof raw?.appName === "string" && raw.appName.trim() ? raw.appName : undefined;
120
+ }
121
+ export function configuredHarnessTargets(projectDir) {
122
+ const raw = loadRafiConfigObject(projectDir);
123
+ const harness = raw?.harness;
124
+ if (!Array.isArray(harness?.targets))
125
+ return undefined;
126
+ const targets = normalizeTargets(harness.targets);
127
+ return targets.length > 0 ? targets : undefined;
128
+ }
129
+ export function detectPackageName(projectDir) {
130
+ const pkgPath = join(projectDir, "package.json");
131
+ if (!existsSync(pkgPath))
132
+ return undefined;
133
+ try {
134
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
135
+ return typeof pkg.name === "string" && pkg.name.trim() ? pkg.name : undefined;
136
+ }
137
+ catch {
138
+ return undefined;
139
+ }
140
+ }
141
+ export function defaultAppName(projectDir) {
142
+ return configuredAppName(projectDir) ?? detectPackageName(projectDir) ?? "My App";
143
+ }
144
+ export function detectGitProvider(projectDir) {
145
+ try {
146
+ const remote = execFileSync("git", ["remote", "get-url", "origin"], {
147
+ cwd: projectDir,
148
+ encoding: "utf8",
149
+ stdio: ["ignore", "pipe", "ignore"],
150
+ }).trim().toLowerCase();
151
+ if (remote.includes("github.com") || remote.includes("github."))
152
+ return "github";
153
+ if (remote.includes("gitlab.com") || remote.includes("gitlab."))
154
+ return "gitlab";
155
+ }
156
+ catch {
157
+ return undefined;
158
+ }
159
+ return undefined;
160
+ }
161
+ export function recommendedBuildDefaults(projectDir) {
162
+ const provider = detectGitProvider(projectDir);
163
+ return {
164
+ ...DEFAULT_TICKET_SETUP.build,
165
+ provider: provider ?? "local",
166
+ completion: provider ? "auto-merge" : "direct-merge",
167
+ pr_ready: Boolean(provider),
168
+ merge_method: "squash",
169
+ cleanup: true,
170
+ };
171
+ }
172
+ function minimalRafiConfig(projectDir, opts) {
173
+ const defaults = loadDefaults();
174
+ const docsRoot = opts.docsRoot ?? "docs";
175
+ const targets = normalizeTargets(opts.targets).length > 0 ? normalizeTargets(opts.targets) : ["claude", "codex"];
176
+ return {
177
+ appName: opts.appName ?? defaultAppName(projectDir),
178
+ timezone: opts.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC",
179
+ stack: {
180
+ frontend: defaults.stack.frontend,
181
+ backend: defaults.stack.backend,
182
+ database: defaults.stack.database,
183
+ cloud: defaults.stack.cloud,
184
+ packageManager: defaults.stack.packageManager,
185
+ },
186
+ flags: {
187
+ hasFrontend: Boolean(defaults.flags.hasFrontend),
188
+ usesAI: Boolean(defaults.flags.usesAI),
189
+ runsInCloud: Boolean(defaults.flags.runsInCloud),
190
+ },
191
+ harness: {
192
+ targets,
193
+ qa: true,
194
+ },
195
+ agent_files: {
196
+ mode: "overwrite",
197
+ codex: "./AGENTS.md",
198
+ claude: "./CLAUDE.md",
199
+ },
200
+ docs: {
201
+ root: docsRoot,
202
+ },
203
+ agents: Object.fromEntries(AGENT_NAMES.map((name) => [name, artifactPaths("agent", name)])),
204
+ skills: Object.fromEntries(SKILL_NAMES.map((name) => [name, artifactPaths("skill", name)])),
205
+ };
206
+ }
207
+ function artifactPaths(kind, name) {
208
+ if (kind === "agent") {
209
+ return {
210
+ artifact_source: "rafi",
211
+ claude: `./.claude/agents/${name}.md`,
212
+ codex: `./.codex/agents/${name}.toml`,
213
+ };
214
+ }
215
+ return {
216
+ artifact_source: "rafi",
217
+ claude: `./.claude/skills/${name}/SKILL.md`,
218
+ codex: `./.agents/skills/${name}/SKILL.md`,
219
+ };
220
+ }
221
+ function normalizeSources(value, label) {
222
+ if (value === undefined)
223
+ return [];
224
+ if (!Array.isArray(value))
225
+ throw new Error(`${label}: expected an array`);
226
+ return value.map((item, index) => normalizeSource(item, `${label}[${index}]`));
227
+ }
228
+ function normalizeSource(value, label) {
229
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
230
+ throw new Error(`${label}: expected an object`);
231
+ }
232
+ const raw = value;
233
+ if (raw.type === "local") {
234
+ if (!Array.isArray(raw.paths) || !raw.paths.every((path) => typeof path === "string" && path.trim())) {
235
+ throw new Error(`${label}.paths: expected non-empty string array`);
236
+ }
237
+ return { type: "local", paths: unique(raw.paths.map((path) => String(path).trim())) };
238
+ }
239
+ if (raw.type === "linear") {
240
+ return {
241
+ type: "linear",
242
+ api_key_env: stringField(raw.api_key_env, "LINEAR_API_KEY"),
243
+ team_key: nullableString(raw.team_key),
244
+ filter: nullableString(raw.filter),
245
+ };
246
+ }
247
+ if (raw.type === "jira") {
248
+ const site = stringField(raw.site);
249
+ const jql = stringField(raw.jql);
250
+ if (!site)
251
+ throw new Error(`${label}.site: expected a non-empty Jira Cloud site URL`);
252
+ if (!jql)
253
+ throw new Error(`${label}.jql: expected a non-empty JQL string`);
254
+ return {
255
+ type: "jira",
256
+ site: site.replace(/\/+$/, ""),
257
+ email_env: stringField(raw.email_env, "JIRA_EMAIL"),
258
+ token_env: stringField(raw.token_env, "JIRA_API_TOKEN"),
259
+ jql,
260
+ };
261
+ }
262
+ throw new Error(`${label}.type: expected local, linear, or jira`);
263
+ }
264
+ function normalizePopulate(value, label) {
265
+ const raw = objectOrEmpty(value, label);
266
+ return {
267
+ source_handling: enumField(raw.source_handling, ["saved", "prompt", "manual"], DEFAULT_TICKET_SETUP.populate.source_handling, `${label}.source_handling`),
268
+ agent_preference: enumField(raw.agent_preference, ["configured", "claude", "codex"], DEFAULT_TICKET_SETUP.populate.agent_preference, `${label}.agent_preference`),
269
+ import_cap: positiveInteger(raw.import_cap, DEFAULT_TICKET_SETUP.populate.import_cap, `${label}.import_cap`),
270
+ comment_limit: nonNegativeInteger(raw.comment_limit, DEFAULT_TICKET_SETUP.populate.comment_limit, `${label}.comment_limit`),
271
+ enrichment: enumField(raw.enrichment, ["none", "recommendations", "agent"], DEFAULT_TICKET_SETUP.populate.enrichment, `${label}.enrichment`),
272
+ recommend_split_for_xl: booleanField(raw.recommend_split_for_xl, DEFAULT_TICKET_SETUP.populate.recommend_split_for_xl, `${label}.recommend_split_for_xl`),
273
+ };
274
+ }
275
+ function normalizeBuild(value, label) {
276
+ const raw = objectOrEmpty(value, label);
277
+ return {
278
+ branch_strategy: enumField(raw.branch_strategy, ["branch-per-ticket", "batch"], DEFAULT_TICKET_SETUP.build.branch_strategy, `${label}.branch_strategy`),
279
+ completion: enumField(raw.completion, ["pr", "auto-merge", "direct-merge", "none"], DEFAULT_TICKET_SETUP.build.completion, `${label}.completion`),
280
+ provider: enumField(raw.provider, ["auto", "github", "gitlab", "local"], DEFAULT_TICKET_SETUP.build.provider, `${label}.provider`),
281
+ pr_ready: booleanField(raw.pr_ready, DEFAULT_TICKET_SETUP.build.pr_ready, `${label}.pr_ready`),
282
+ merge_method: enumField(raw.merge_method, ["squash", "merge", "rebase"], DEFAULT_TICKET_SETUP.build.merge_method, `${label}.merge_method`),
283
+ cleanup: booleanField(raw.cleanup, DEFAULT_TICKET_SETUP.build.cleanup, `${label}.cleanup`),
284
+ auto_merge_wait: booleanField(raw.auto_merge_wait, DEFAULT_TICKET_SETUP.build.auto_merge_wait, `${label}.auto_merge_wait`),
285
+ auto_merge_timeout_minutes: nullablePositiveInteger(raw.auto_merge_timeout_minutes, DEFAULT_TICKET_SETUP.build.auto_merge_timeout_minutes, `${label}.auto_merge_timeout_minutes`),
286
+ };
287
+ }
288
+ function objectOrEmpty(value, label) {
289
+ if (value === undefined)
290
+ return {};
291
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
292
+ throw new Error(`${label}: expected an object`);
293
+ }
294
+ return value;
295
+ }
296
+ function enumField(value, allowed, fallback, label) {
297
+ if (value === undefined)
298
+ return fallback;
299
+ if (typeof value === "string" && allowed.includes(value))
300
+ return value;
301
+ throw new Error(`${label}: expected one of ${allowed.join(", ")}`);
302
+ }
303
+ function stringField(value, fallback) {
304
+ if (value === undefined || value === null || value === "") {
305
+ if (fallback !== undefined)
306
+ return fallback;
307
+ return "";
308
+ }
309
+ if (typeof value !== "string" || !value.trim())
310
+ throw new Error("expected a non-empty string");
311
+ return value.trim();
312
+ }
313
+ function nullableString(value) {
314
+ if (value === undefined || value === null || value === "")
315
+ return null;
316
+ if (typeof value !== "string")
317
+ throw new Error("expected a string or null");
318
+ return value.trim() || null;
319
+ }
320
+ function booleanField(value, fallback, label) {
321
+ if (value === undefined)
322
+ return fallback;
323
+ if (typeof value === "boolean")
324
+ return value;
325
+ throw new Error(`${label}: expected a boolean`);
326
+ }
327
+ function positiveInteger(value, fallback, label) {
328
+ if (value === undefined)
329
+ return fallback;
330
+ if (Number.isInteger(value) && Number(value) > 0)
331
+ return Number(value);
332
+ throw new Error(`${label}: expected a positive integer`);
333
+ }
334
+ function nonNegativeInteger(value, fallback, label) {
335
+ if (value === undefined)
336
+ return fallback;
337
+ if (Number.isInteger(value) && Number(value) >= 0)
338
+ return Number(value);
339
+ throw new Error(`${label}: expected a non-negative integer`);
340
+ }
341
+ function nullablePositiveInteger(value, fallback, label) {
342
+ if (value === undefined)
343
+ return fallback;
344
+ if (value === null)
345
+ return null;
346
+ if (Number.isInteger(value) && Number(value) > 0)
347
+ return Number(value);
348
+ throw new Error(`${label}: expected a positive integer or null`);
349
+ }
350
+ function normalizeTargets(value) {
351
+ if (!Array.isArray(value))
352
+ return [];
353
+ const out = [];
354
+ for (const item of value) {
355
+ if ((item === "claude" || item === "codex") && !out.includes(item))
356
+ out.push(item);
357
+ }
358
+ return out;
359
+ }
360
+ function cloneTicketSetup(setup) {
361
+ return {
362
+ sources: setup.sources.map((source) => ({ ...source, ...(source.type === "local" ? { paths: [...source.paths] } : {}) })),
363
+ populate: { ...setup.populate },
364
+ build: { ...setup.build },
365
+ };
366
+ }
367
+ function unique(values) {
368
+ return [...new Set(values)];
369
+ }