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,482 @@
1
+ import { Command } from "commander";
2
+ import { existsSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { select, isCancel } from "@clack/prompts";
5
+ import { loadConfig } from "../config.js";
6
+ import { Log } from "../log.js";
7
+ import { PermissionPolicy } from "../permissions/policy.js";
8
+ import { ClaudeAdapter } from "../adapters/claude.js";
9
+ import { CodexAdapter } from "../adapters/codex.js";
10
+ import { Foreman, createPermissionHandler } from "../foreman.js";
11
+ import { printEvents } from "./events.js";
12
+ import { cmdInit, cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdCancel, cmdDiscover, cmdAcceptFutureWork, cmdReorder, cmdRender, cmdValidate, cmdQueue, cmdArchive, } from "../tickets/commands.js";
13
+ import { isTicketsInitialized } from "../tickets/config.js";
14
+ import { importFromMarkdown } from "../tickets/importer.js";
15
+ import { formatValidationIssues } from "../tickets/validate.js";
16
+ function fail(msg) {
17
+ console.error(`foreman tickets: ${msg}`);
18
+ process.exit(1);
19
+ }
20
+ function cwd(opts) {
21
+ return resolve(opts.project ?? ".");
22
+ }
23
+ const VALID_AGENTS = ["claude", "codex"];
24
+ const VALID_EFFORT = ["low", "medium", "high", "xhigh"];
25
+ function validateAgent(agent) {
26
+ if (!VALID_AGENTS.includes(agent)) {
27
+ fail(`unknown agent "${agent}" — choose: ${VALID_AGENTS.join(" | ")}`);
28
+ }
29
+ }
30
+ function validateEffort(effort) {
31
+ if (effort && !VALID_EFFORT.includes(effort)) {
32
+ fail(`unknown effort "${effort}" — choose: ${VALID_EFFORT.join(" | ")}`);
33
+ }
34
+ }
35
+ function makeLogPath(projectDir, label) {
36
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
37
+ return join(projectDir, ".foreman", `${stamp}-${label}.jsonl`);
38
+ }
39
+ export function buildPopulateInstruction() {
40
+ return `You are being run by Foreman to populate this repository's Foreman ticket tracker.
41
+
42
+ Goal:
43
+ - Convert every existing project ticket, task, backlog item, roadmap item, or implementation step into Foreman's structured ticket system.
44
+ - Do not implement product/code changes. Only update ticket-tracker files.
45
+
46
+ Read these files first:
47
+ - .tickets/config.yaml
48
+ - .tickets/tickets.yaml
49
+ - .tickets/tracker-rules.md
50
+ - docs/ticket-progress.md if it exists
51
+
52
+ Then inspect the repository for existing planning sources. Check root and docs-style Markdown/YAML/TXT files whose names suggest tickets, backlog, roadmap, plan, TODOs, milestones, progress, specs, phases, or implementation steps. Preserve every ticket or task you find. Do not leave out details.
53
+
54
+ Write the canonical ticket definitions to .tickets/tickets.yaml using Foreman's schema:
55
+ - id: stable ticket ID. Preserve existing IDs. If no IDs exist, assign T001, T002, ... in implementation order.
56
+ - order: unique numeric implementation order. Use gaps like 1000, 2000, 3000.
57
+ - title, area, priority, size, risk, depends_on, summary, acceptance, required_tests, likely_files, rollback, notes.
58
+ - Keep dependencies, acceptance criteria, testing expectations, file hints, risk notes, and implementation notes from the source material.
59
+ - Do not store mutable status/progress fields in .tickets/tickets.yaml.
60
+ - Do not edit .tickets/ticket-state.sqlite directly.
61
+
62
+ If source content does not cleanly map to the new schema, ask for guidance instead of guessing. Use:
63
+ STEP_STATUS: needs_input | question="..." choices="..."
64
+
65
+ After editing:
66
+ - Run foreman tickets render.
67
+ - Run foreman tickets validate.
68
+ - Fix validation errors if possible.
69
+ - Triple-check that every source ticket/task is represented exactly once and no important detail was dropped.
70
+
71
+ End with exactly one marker line as the final non-empty line:
72
+ STEP_STATUS: done | summary="populated Foreman tickets from existing project ticket sources"
73
+ or
74
+ STEP_STATUS: blocked | reason="why ticket population cannot proceed"`;
75
+ }
76
+ export function buildTicketsCommand() {
77
+ const tickets = new Command("tickets").description("Manage the structured ticket tracker for a project.");
78
+ // ── init ────────────────────────────────────────────────────────────────────
79
+ tickets
80
+ .command("init")
81
+ .description("Initialize .tickets/ structure in a project directory.")
82
+ .option("-p, --project <dir>", "project directory (default: cwd)")
83
+ .option("--app-name <name>", "application name")
84
+ .option("--timezone <tz>", "IANA timezone (e.g. America/Chicago)", "UTC")
85
+ .option("--queue-limit <n>", "next-queue window size", "50")
86
+ .action((opts) => {
87
+ const dir = cwd(opts);
88
+ try {
89
+ cmdInit(dir, {
90
+ appName: opts.appName,
91
+ timezone: opts.timezone,
92
+ queueLimit: Number(opts.queueLimit),
93
+ });
94
+ console.log(`foreman tickets: initialized .tickets/ in ${dir}`);
95
+ console.log(`foreman tickets: next — add tickets to .tickets/tickets.yaml and run \`foreman tickets render\``);
96
+ }
97
+ catch (err) {
98
+ fail(String(err instanceof Error ? err.message : err));
99
+ }
100
+ });
101
+ // ── populate ────────────────────────────────────────────────────────────────
102
+ tickets
103
+ .command("populate")
104
+ .description("Ask a builder to populate .tickets/tickets.yaml from existing project ticket/backlog docs.")
105
+ .option("-p, --project <dir>", "project directory (default: cwd)")
106
+ .option("-a, --agent <agent>", "builder agent (claude | codex)", "claude")
107
+ .option("-m, --model <model>", "override the builder's model")
108
+ .option("--effort <level>", "reasoning effort level (low|medium|high|xhigh)")
109
+ .option("--fast", "fast mode - lower latency")
110
+ .option("-y, --yes", "skip confirmation prompt before letting the builder edit tickets")
111
+ .action(async (opts) => {
112
+ const dir = cwd(opts);
113
+ const agent = opts.agent;
114
+ validateAgent(agent);
115
+ validateEffort(opts.effort);
116
+ if (!existsSync(dir))
117
+ fail(`project directory not found: ${dir}`);
118
+ if (!isTicketsInitialized(dir)) {
119
+ fail(`ticket tracker is not initialized in ${dir}; run \`foreman tickets init --project ${dir}\` first`);
120
+ }
121
+ if (!opts.yes) {
122
+ const action = await select({
123
+ message: "Populate .tickets/tickets.yaml by letting the builder edit this project?",
124
+ options: [
125
+ { value: "proceed", label: "Proceed - builder may edit ticket files" },
126
+ { value: "cancel", label: "Cancel" },
127
+ ],
128
+ });
129
+ if (isCancel(action) || action === "cancel") {
130
+ console.log("foreman tickets: cancelled");
131
+ process.exit(0);
132
+ }
133
+ }
134
+ const config = loadConfig(join(dir, "foreman.yaml"));
135
+ const logPath = makeLogPath(dir, "tickets-populate");
136
+ const log = new Log(logPath);
137
+ const policy = new PermissionPolicy(config.permissions, dir);
138
+ const adapterOpts = {
139
+ cwd: dir,
140
+ model: opts.model,
141
+ permission: createPermissionHandler(policy, log),
142
+ effort: opts.effort,
143
+ fast: opts.fast,
144
+ };
145
+ const builder = agent === "codex"
146
+ ? new CodexAdapter(adapterOpts)
147
+ : new ClaudeAdapter(adapterOpts);
148
+ const viewer = printEvents(builder.events());
149
+ const foreman = new Foreman(builder, log, config.notifications.enabled, false, 3, dir);
150
+ console.log(`foreman tickets: populating tickets with ${agent}`);
151
+ console.log(`foreman tickets: project ${dir}`);
152
+ console.log(`foreman tickets: log ${logPath}\n`);
153
+ try {
154
+ const turn = await foreman.runInstruction(buildPopulateInstruction());
155
+ await builder.close();
156
+ await viewer;
157
+ log.write("ticket-populate", {
158
+ statusKind: turn.status.kind,
159
+ summary: turn.status.summary,
160
+ reason: turn.status.reason,
161
+ costUsd: turn.result.costUsd,
162
+ isError: turn.result.isError,
163
+ });
164
+ if (turn.result.isError) {
165
+ fail(`builder turn errored: ${turn.result.text.slice(0, 200)}`);
166
+ }
167
+ if (turn.status.kind === "blocked") {
168
+ console.error(`foreman tickets: blocked — ${turn.status.reason ?? "builder reported blocked"}`);
169
+ process.exit(2);
170
+ }
171
+ if (turn.status.kind !== "done" && turn.status.kind !== "plan_complete") {
172
+ console.error(`foreman tickets: needs human — ${turn.status.error ?? "builder did not emit done"}`);
173
+ process.exit(2);
174
+ }
175
+ cmdRender(dir);
176
+ const validation = cmdValidate(dir);
177
+ if (validation.issues.length > 0) {
178
+ console.log(`foreman tickets: ${validation.issues.length} validation issue(s) found:`);
179
+ console.log(formatValidationIssues(validation.issues));
180
+ if (!validation.clean)
181
+ process.exit(1);
182
+ }
183
+ else {
184
+ console.log("foreman tickets: validation passed — all 4 passes clean");
185
+ }
186
+ console.log("foreman tickets: populated .tickets/tickets.yaml and rendered docs/ticket-progress.md");
187
+ }
188
+ catch (err) {
189
+ await builder.close().catch(() => { });
190
+ fail(String(err instanceof Error ? err.message : err));
191
+ }
192
+ });
193
+ // ── update ──────────────────────────────────────────────────────────────────
194
+ tickets
195
+ .command("update <ticketId>")
196
+ .description("Update ticket status or progress fields.")
197
+ .option("-p, --project <dir>", "project directory (default: cwd)")
198
+ .option("--status <status>", "new status (planned|next|in_progress|blocked|done|canceled)")
199
+ .option("--actor <actor>", "who is making this update")
200
+ .option("--summary <text>", "short description of the update")
201
+ .option("--next-action <text>", "what comes next for this ticket")
202
+ .option("--current-step <text>", "current implementation step")
203
+ .option("--owner <name>", "ticket owner")
204
+ .option("--validation-result <result>", "passed|failed|not_run|not_applicable")
205
+ .option("--validation-commands <cmds>", "commands used to validate")
206
+ .option("--evidence <text>", "evidence of correctness")
207
+ .option("--last-error <text>", "last error message if tests failed")
208
+ .action((ticketId, opts) => {
209
+ try {
210
+ cmdUpdate(cwd(opts), ticketId, {
211
+ status: opts.status,
212
+ actor: opts.actor,
213
+ summary: opts.summary,
214
+ nextAction: opts.nextAction,
215
+ currentStep: opts.currentStep,
216
+ owner: opts.owner,
217
+ validationResult: opts.validationResult,
218
+ validationCommands: opts.validationCommands,
219
+ evidence: opts.evidence,
220
+ lastError: opts.lastError,
221
+ });
222
+ console.log(`foreman tickets: updated ${ticketId}`);
223
+ }
224
+ catch (err) {
225
+ fail(String(err instanceof Error ? err.message : err));
226
+ }
227
+ });
228
+ // ── complete ────────────────────────────────────────────────────────────────
229
+ tickets
230
+ .command("complete <ticketId>")
231
+ .description("Mark a ticket done with validation evidence.")
232
+ .option("-p, --project <dir>", "project directory (default: cwd)")
233
+ .option("--actor <actor>", "who completed this ticket")
234
+ .option("--summary <text>", "completion summary")
235
+ .option("--validation-result <result>", "passed|failed|not_run|not_applicable", "passed")
236
+ .option("--validation-commands <cmds>", "commands used to validate")
237
+ .option("--evidence <text>", "evidence of correctness (required unless not_applicable)")
238
+ .option("--validation-notes <text>", "extra notes about validation")
239
+ .action((ticketId, opts) => {
240
+ try {
241
+ cmdComplete(cwd(opts), ticketId, {
242
+ actor: opts.actor,
243
+ summary: opts.summary,
244
+ validationResult: opts.validationResult,
245
+ validationCommands: opts.validationCommands,
246
+ evidence: opts.evidence,
247
+ validationNotes: opts.validationNotes,
248
+ });
249
+ console.log(`foreman tickets: completed ${ticketId}`);
250
+ }
251
+ catch (err) {
252
+ fail(String(err instanceof Error ? err.message : err));
253
+ }
254
+ });
255
+ // ── block ────────────────────────────────────────────────────────────────────
256
+ tickets
257
+ .command("block <ticketId>")
258
+ .description("Mark a ticket as blocked.")
259
+ .option("-p, --project <dir>", "project directory (default: cwd)")
260
+ .option("--blocked-by <ids...>", "ticket IDs or labels that are blocking")
261
+ .option("--type <type>", "blocker type (dependency|external|decision|...)")
262
+ .option("--summary <text>", "description of the blocker")
263
+ .option("--unblock-criteria <text>", "what needs to happen to unblock")
264
+ .option("--actor <actor>", "who is recording this blocker")
265
+ .action((ticketId, opts) => {
266
+ try {
267
+ cmdBlock(cwd(opts), ticketId, {
268
+ blockedBy: opts.blockedBy,
269
+ blockerType: opts.type,
270
+ summary: opts.summary,
271
+ actor: opts.actor,
272
+ unblockCriteria: opts.unblockCriteria,
273
+ });
274
+ console.log(`foreman tickets: blocked ${ticketId}`);
275
+ }
276
+ catch (err) {
277
+ fail(String(err instanceof Error ? err.message : err));
278
+ }
279
+ });
280
+ // ── unblock ──────────────────────────────────────────────────────────────────
281
+ tickets
282
+ .command("unblock <ticketId>")
283
+ .description("Remove explicit blockers from a ticket.")
284
+ .option("-p, --project <dir>", "project directory (default: cwd)")
285
+ .option("--summary <text>", "description of how it was unblocked")
286
+ .option("--actor <actor>", "who resolved the blocker")
287
+ .action((ticketId, opts) => {
288
+ try {
289
+ cmdUnblock(cwd(opts), ticketId, {
290
+ summary: opts.summary,
291
+ actor: opts.actor,
292
+ });
293
+ console.log(`foreman tickets: unblocked ${ticketId}`);
294
+ }
295
+ catch (err) {
296
+ fail(String(err instanceof Error ? err.message : err));
297
+ }
298
+ });
299
+ // ── cancel ───────────────────────────────────────────────────────────────────
300
+ tickets
301
+ .command("cancel <ticketId>")
302
+ .description("Cancel a ticket.")
303
+ .option("-p, --project <dir>", "project directory (default: cwd)")
304
+ .requiredOption("--summary <text>", "reason for cancellation")
305
+ .option("--actor <actor>", "who canceled this ticket")
306
+ .action((ticketId, opts) => {
307
+ try {
308
+ cmdCancel(cwd(opts), ticketId, {
309
+ summary: opts.summary,
310
+ actor: opts.actor,
311
+ });
312
+ console.log(`foreman tickets: canceled ${ticketId}`);
313
+ }
314
+ catch (err) {
315
+ fail(String(err instanceof Error ? err.message : err));
316
+ }
317
+ });
318
+ // ── discover ─────────────────────────────────────────────────────────────────
319
+ tickets
320
+ .command("discover")
321
+ .description("Add newly discovered future work to the inbox.")
322
+ .option("-p, --project <dir>", "project directory (default: cwd)")
323
+ .requiredOption("--summary <text>", "short description of the discovered work")
324
+ .option("--source-ticket <id>", "ticket that led to this discovery")
325
+ .option("--proposed-ticket <id>", "proposed ticket ID")
326
+ .option("--priority-guess <p>", "P0|P1|P2|P3")
327
+ .option("--area <area>", "product/code area")
328
+ .option("--rationale <text>", "why this work is needed")
329
+ .option("--needs-decision-from <who>", "who needs to decide")
330
+ .option("--actor <actor>", "who discovered this")
331
+ .action((opts) => {
332
+ try {
333
+ const id = cmdDiscover(cwd(opts), {
334
+ summary: opts.summary,
335
+ sourceTicket: opts.sourceTicket,
336
+ proposedTicket: opts.proposedTicket,
337
+ priorityGuess: opts.priorityGuess,
338
+ area: opts.area,
339
+ rationale: opts.rationale,
340
+ needsDecisionFrom: opts.needsDecisionFrom,
341
+ actor: opts.actor,
342
+ });
343
+ console.log(`foreman tickets: logged future work item #${id}`);
344
+ }
345
+ catch (err) {
346
+ fail(String(err instanceof Error ? err.message : err));
347
+ }
348
+ });
349
+ // ── accept-future-work ────────────────────────────────────────────────────────
350
+ tickets
351
+ .command("accept-future-work <futureWorkId>")
352
+ .description("Promote a future-work item into tickets.yaml as a new ticket.")
353
+ .option("-p, --project <dir>", "project directory (default: cwd)")
354
+ .requiredOption("--ticket-id <id>", "new ticket ID (e.g. T051)")
355
+ .requiredOption("--order <n>", "canonical implementation order (e.g. 51000)")
356
+ .option("--actor <actor>", "who accepted this item")
357
+ .action((futureWorkId, opts) => {
358
+ try {
359
+ cmdAcceptFutureWork(cwd(opts), Number(futureWorkId), {
360
+ ticketId: opts.ticketId,
361
+ order: Number(opts.order),
362
+ actor: opts.actor,
363
+ });
364
+ console.log(`foreman tickets: accepted future work #${futureWorkId} as ${opts.ticketId}`);
365
+ }
366
+ catch (err) {
367
+ fail(String(err instanceof Error ? err.message : err));
368
+ }
369
+ });
370
+ // ── reorder ──────────────────────────────────────────────────────────────────
371
+ tickets
372
+ .command("reorder <ticketId>")
373
+ .description("Change the canonical implementation order of a ticket.")
374
+ .option("-p, --project <dir>", "project directory (default: cwd)")
375
+ .option("--after <ticketId>", "place this ticket immediately after another")
376
+ .option("--order <n>", "set explicit order value")
377
+ .option("--actor <actor>", "who reordered this")
378
+ .action((ticketId, opts) => {
379
+ try {
380
+ cmdReorder(cwd(opts), ticketId, {
381
+ afterTicketId: opts.after,
382
+ order: opts.order ? Number(opts.order) : undefined,
383
+ actor: opts.actor,
384
+ });
385
+ console.log(`foreman tickets: reordered ${ticketId}`);
386
+ }
387
+ catch (err) {
388
+ fail(String(err instanceof Error ? err.message : err));
389
+ }
390
+ });
391
+ // ── render ───────────────────────────────────────────────────────────────────
392
+ tickets
393
+ .command("render")
394
+ .description("Regenerate docs/ticket-progress.md from current structured sources.")
395
+ .option("-p, --project <dir>", "project directory (default: cwd)")
396
+ .action((opts) => {
397
+ try {
398
+ cmdRender(cwd(opts));
399
+ console.log("foreman tickets: rendered docs/ticket-progress.md");
400
+ }
401
+ catch (err) {
402
+ fail(String(err instanceof Error ? err.message : err));
403
+ }
404
+ });
405
+ // ── validate ─────────────────────────────────────────────────────────────────
406
+ tickets
407
+ .command("validate")
408
+ .description("Run all 4 validation passes. Exits 1 on error.")
409
+ .option("-p, --project <dir>", "project directory (default: cwd)")
410
+ .action((opts) => {
411
+ try {
412
+ const result = cmdValidate(cwd(opts));
413
+ if (result.issues.length === 0) {
414
+ console.log("foreman tickets: validation passed — all 4 passes clean");
415
+ }
416
+ else {
417
+ console.log(`foreman tickets: ${result.issues.length} issue(s) found:`);
418
+ console.log(formatValidationIssues(result.issues));
419
+ if (!result.clean)
420
+ process.exit(1);
421
+ }
422
+ }
423
+ catch (err) {
424
+ fail(String(err instanceof Error ? err.message : err));
425
+ }
426
+ });
427
+ // ── queue ─────────────────────────────────────────────────────────────────────
428
+ tickets
429
+ .command("queue")
430
+ .description("Print the next-N queue to stdout.")
431
+ .option("-p, --project <dir>", "project directory (default: cwd)")
432
+ .option("--limit <n>", "override queue limit")
433
+ .action((opts) => {
434
+ try {
435
+ const rows = cmdQueue(cwd(opts), opts.limit ? Number(opts.limit) : undefined);
436
+ if (rows.length === 0) {
437
+ console.log("No remaining tickets.");
438
+ }
439
+ else {
440
+ for (const r of rows) {
441
+ console.log(` ${r.rank}. [${r.status}] ${r.ticket}: ${r.title} (${r.priority}, blocked: ${r.blockedBy})`);
442
+ }
443
+ }
444
+ }
445
+ catch (err) {
446
+ fail(String(err instanceof Error ? err.message : err));
447
+ }
448
+ });
449
+ // ── archive ───────────────────────────────────────────────────────────────────
450
+ tickets
451
+ .command("archive")
452
+ .description("Update docs/ticket-archive.md and prune old completed rows.")
453
+ .option("-p, --project <dir>", "project directory (default: cwd)")
454
+ .option("--older-than-days <n>", "only archive tickets completed more than N days ago")
455
+ .action((opts) => {
456
+ try {
457
+ cmdArchive(cwd(opts), {
458
+ olderThanDays: opts.olderThanDays ? Number(opts.olderThanDays) : undefined,
459
+ });
460
+ console.log("foreman tickets: archive pass complete");
461
+ }
462
+ catch (err) {
463
+ fail(String(err instanceof Error ? err.message : err));
464
+ }
465
+ });
466
+ // ── import ────────────────────────────────────────────────────────────────────
467
+ tickets
468
+ .command("import")
469
+ .description("(stub) Migrate an existing Markdown tracker.")
470
+ .option("-p, --project <dir>", "project directory (default: cwd)")
471
+ .option("--progress <path>", "path to existing docs/ticket-progress.md")
472
+ .action((opts) => {
473
+ try {
474
+ importFromMarkdown(opts.progress ?? "docs/ticket-progress.md");
475
+ }
476
+ catch (err) {
477
+ console.error(String(err instanceof Error ? err.message : err));
478
+ process.exit(1);
479
+ }
480
+ });
481
+ return tickets;
482
+ }
package/dist/config.js ADDED
@@ -0,0 +1,119 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { parse } from "yaml";
3
+ /** Built-in defaults; foreman.yaml overrides any field present. */
4
+ export const DEFAULT_CONFIG = {
5
+ permissions: {
6
+ allowBash: [
7
+ "npm test",
8
+ "npm run",
9
+ "npm install",
10
+ "npm ci",
11
+ "npx tsc",
12
+ "node ",
13
+ "pnpm ",
14
+ "yarn ",
15
+ "git status",
16
+ "git diff",
17
+ "git add",
18
+ "git commit",
19
+ "git log",
20
+ "git branch",
21
+ "git checkout",
22
+ "git restore",
23
+ "git stash",
24
+ "ls",
25
+ "cat ",
26
+ "mkdir ",
27
+ "foreman tickets",
28
+ "pytest",
29
+ "python ",
30
+ "python3 ",
31
+ "make ",
32
+ "cargo ",
33
+ "go test",
34
+ "go build",
35
+ ],
36
+ escalateBash: [
37
+ "rm -rf",
38
+ "rm -r",
39
+ "sudo",
40
+ "git push",
41
+ "git reset --hard",
42
+ "git clean",
43
+ "curl",
44
+ "wget",
45
+ "ssh",
46
+ "scp",
47
+ "docker",
48
+ "kubectl",
49
+ "chmod 777",
50
+ "> /dev",
51
+ ":(){",
52
+ "mkfs",
53
+ "dd if=",
54
+ ],
55
+ allowTools: [
56
+ "Read",
57
+ "Glob",
58
+ "Grep",
59
+ "Edit",
60
+ "Write",
61
+ "MultiEdit",
62
+ "NotebookEdit",
63
+ "TodoWrite",
64
+ ],
65
+ escalateTools: ["WebFetch", "WebSearch"],
66
+ },
67
+ notifications: { enabled: false },
68
+ qa: { enabled: true },
69
+ };
70
+ /** Load foreman.yaml if present and deep-merge it over the defaults. */
71
+ export function loadConfig(path = "foreman.yaml") {
72
+ if (!existsSync(path))
73
+ return DEFAULT_CONFIG;
74
+ const raw = parse(readFileSync(path, "utf8")) ?? {};
75
+ validateConfig(raw, path);
76
+ return {
77
+ permissions: { ...DEFAULT_CONFIG.permissions, ...(raw.permissions ?? {}) },
78
+ notifications: {
79
+ ...DEFAULT_CONFIG.notifications,
80
+ ...(raw.notifications ?? {}),
81
+ },
82
+ qa: { ...DEFAULT_CONFIG.qa, ...(raw.qa ?? {}) },
83
+ };
84
+ }
85
+ function validateConfig(raw, path) {
86
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
87
+ throw new Error(`${path}: expected a YAML object`);
88
+ }
89
+ const cfg = raw;
90
+ if (cfg.permissions !== undefined) {
91
+ if (!cfg.permissions || typeof cfg.permissions !== "object" || Array.isArray(cfg.permissions)) {
92
+ throw new Error(`${path}: permissions must be an object`);
93
+ }
94
+ const permissions = cfg.permissions;
95
+ for (const key of ["allowBash", "escalateBash", "allowTools", "escalateTools"]) {
96
+ if (permissions[key] !== undefined && !isStringArray(permissions[key])) {
97
+ throw new Error(`${path}: permissions.${key} must be an array of strings`);
98
+ }
99
+ }
100
+ }
101
+ if (cfg.notifications !== undefined) {
102
+ validateBooleanObject(cfg.notifications, "notifications", path);
103
+ }
104
+ if (cfg.qa !== undefined) {
105
+ validateBooleanObject(cfg.qa, "qa", path);
106
+ }
107
+ }
108
+ function validateBooleanObject(value, name, path) {
109
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
110
+ throw new Error(`${path}: ${name} must be an object`);
111
+ }
112
+ const enabled = value.enabled;
113
+ if (enabled !== undefined && typeof enabled !== "boolean") {
114
+ throw new Error(`${path}: ${name}.enabled must be a boolean`);
115
+ }
116
+ }
117
+ function isStringArray(value) {
118
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
119
+ }