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/dist/index.js ADDED
@@ -0,0 +1,300 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { resolve, join, relative } from "node:path";
4
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
5
+ import { spawnSync } from "node:child_process";
6
+ import { select, text, isCancel } from "@clack/prompts";
7
+ import { loadConfig } from "./config.js";
8
+ import { Log } from "./log.js";
9
+ import { PermissionPolicy } from "./permissions/policy.js";
10
+ import { ClaudeAdapter } from "./adapters/claude.js";
11
+ import { CodexAdapter } from "./adapters/codex.js";
12
+ import { Foreman, createPermissionHandler } from "./foreman.js";
13
+ import { buildTicketsCommand } from "./cli/tickets.js";
14
+ import { printEvents } from "./cli/events.js";
15
+ import { isTicketsInitialized } from "./tickets/config.js";
16
+ import { cmdValidate } from "./tickets/commands.js";
17
+ import { loadRoleBundle } from "./roles.js";
18
+ const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))?.version;
19
+ const program = new Command();
20
+ program
21
+ .name("ai-foreman")
22
+ .description("Keep Codex / Claude Code builders moving through their step list.")
23
+ .version(PACKAGE_VERSION);
24
+ program.addCommand(buildTicketsCommand());
25
+ program
26
+ .command("start")
27
+ .description("Enlist a builder and drive it through a batch of N steps.")
28
+ .argument("<project>", "path to the project directory the builder works in")
29
+ .requiredOption("-s, --steps <n>", "number of steps to drive")
30
+ .option("-a, --agent <agent>", "builder agent (claude | codex)", "claude")
31
+ .option("-m, --model <model>", "override the builder's model")
32
+ .option("-r, --resume <sessionId>", "resume a prior builder session")
33
+ .option("--continue", "resume the most recent logged session for this project")
34
+ .option("-t, --tickets <path>", "path to ticket file (.md, .txt, .yaml, …) — passed to the builder as context")
35
+ .option("-y, --yes", "skip pre-flight confirmation prompt")
36
+ .option("--effort <level>", "reasoning effort level (low|medium|high|xhigh)")
37
+ .option("--fast", "fast mode — lower latency (maps to effort=low for codex)")
38
+ .option("--no-qa", "disable per-ticket QA review (enabled by default)")
39
+ .action(async (project, opts) => {
40
+ const steps = Number.parseInt(opts.steps, 10);
41
+ if (!Number.isInteger(steps) || steps < 1) {
42
+ fail("--steps must be a positive integer");
43
+ }
44
+ const VALID_AGENTS = ["claude", "codex"];
45
+ if (!VALID_AGENTS.includes(opts.agent)) {
46
+ fail(`unknown agent "${opts.agent}" — choose: ${VALID_AGENTS.join(" | ")}`);
47
+ }
48
+ const VALID_EFFORT = ["low", "medium", "high", "xhigh"];
49
+ if (opts.effort && !VALID_EFFORT.includes(opts.effort)) {
50
+ fail(`unknown effort "${opts.effort}" — choose: ${VALID_EFFORT.join(" | ")}`);
51
+ }
52
+ const cwd = resolve(project);
53
+ if (!existsSync(cwd))
54
+ fail(`project directory not found: ${cwd}`);
55
+ // Locate ticket progress tracker: explicit --tickets > auto-detect standard paths
56
+ const TRACKER_SEARCH_PATHS = ["docs/ticket-progress.md", "ticket-progress.md"];
57
+ let ticketsContent;
58
+ let trackerRelPath;
59
+ if (opts.tickets) {
60
+ const ticketPath = resolve(opts.tickets);
61
+ if (!existsSync(ticketPath))
62
+ fail(`ticket file not found: ${ticketPath}`);
63
+ ticketsContent = readFileSync(ticketPath, "utf8");
64
+ trackerRelPath = relative(cwd, ticketPath);
65
+ }
66
+ else {
67
+ for (const rel of TRACKER_SEARCH_PATHS) {
68
+ const abs = join(cwd, rel);
69
+ if (existsSync(abs)) {
70
+ ticketsContent = readFileSync(abs, "utf8");
71
+ trackerRelPath = rel;
72
+ break;
73
+ }
74
+ }
75
+ }
76
+ if (opts.resume && opts.continue) {
77
+ fail("choose either --resume <sessionId> or --continue, not both");
78
+ }
79
+ const resumeSessionId = opts.resume ??
80
+ (opts.continue ? findLastSessionId(join(cwd, ".foreman")) : undefined);
81
+ if (opts.continue && !resumeSessionId) {
82
+ fail(`no previous session id found under ${join(cwd, ".foreman")}`);
83
+ }
84
+ const config = loadConfig(join(cwd, "foreman.yaml"));
85
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
86
+ const logPath = join(cwd, ".foreman", `${stamp}.jsonl`);
87
+ const log = new Log(logPath);
88
+ const policy = new PermissionPolicy(config.permissions, cwd);
89
+ const roleBundle = loadRoleBundle("builder", { projectDir: cwd });
90
+ const adapterOpts = {
91
+ cwd,
92
+ model: opts.model,
93
+ resumeSessionId,
94
+ permission: createPermissionHandler(policy, log),
95
+ effort: opts.effort,
96
+ fast: opts.fast,
97
+ systemPromptAppend: roleBundle.system || undefined,
98
+ skills: roleBundle.skills.length > 0 ? roleBundle.skills : undefined,
99
+ };
100
+ const builder = opts.agent === "codex"
101
+ ? new CodexAdapter(adapterOpts)
102
+ : new ClaudeAdapter(adapterOpts);
103
+ // Commander surfaces --no-qa as opts.qa === false; when --qa/--no-qa is not
104
+ // passed, opts.qa is undefined and we fall back to the config default.
105
+ const qaEnabled = opts.qa !== false && config.qa.enabled !== false;
106
+ const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, cwd);
107
+ const modifiers = [
108
+ opts.model ? `model=${opts.model}` : null,
109
+ opts.effort ? `effort=${opts.effort}` : null,
110
+ opts.fast ? "fast" : null,
111
+ qaEnabled ? null : "qa=off",
112
+ ].filter(Boolean).join(" ");
113
+ console.log(`foreman: driving a ${opts.agent} builder through ${steps} step(s)${modifiers ? ` [${modifiers}]` : ""}`);
114
+ console.log(`foreman: project ${cwd}`);
115
+ if (trackerRelPath)
116
+ console.log(`foreman: tracker ${trackerRelPath}`);
117
+ console.log(`foreman: log ${logPath}\n`);
118
+ const viewer = printEvents(builder.events());
119
+ try {
120
+ // Pre-flight: builder lists the next N tickets or steps; user confirms before any step runs
121
+ console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
122
+ await foreman.runPreflight(steps, ticketsContent);
123
+ if (!opts.yes) {
124
+ while (true) {
125
+ console.log();
126
+ const action = await select({
127
+ message: "How does this plan look?",
128
+ options: [
129
+ { value: "proceed", label: "Proceed — start implementing" },
130
+ { value: "feedback", label: "Give feedback — revise the plan" },
131
+ { value: "cancel", label: "Cancel" },
132
+ ],
133
+ });
134
+ if (isCancel(action) || action === "cancel") {
135
+ console.log("ai-foreman: cancelled");
136
+ await builder.close();
137
+ await viewer;
138
+ process.exit(0);
139
+ }
140
+ if (action === "proceed") {
141
+ console.log();
142
+ break;
143
+ }
144
+ const fb = await text({
145
+ message: "Your feedback:",
146
+ validate: (v) => (v?.trim() ? undefined : "Please enter some feedback"),
147
+ });
148
+ if (isCancel(fb)) {
149
+ console.log("ai-foreman: cancelled");
150
+ await builder.close();
151
+ await viewer;
152
+ process.exit(0);
153
+ }
154
+ console.log();
155
+ await foreman.sendPreflightFeedback(String(fb));
156
+ }
157
+ }
158
+ const result = await foreman.runBatch(steps, trackerRelPath);
159
+ await builder.close();
160
+ await viewer;
161
+ console.log(`\nforeman: ${result.completed}/${result.requested} step(s) completed`);
162
+ console.log(`foreman: outcome — ${result.outcome}`);
163
+ if (result.detail)
164
+ console.log(`foreman: ${result.detail}`);
165
+ const sid = builder.sessionId();
166
+ if (sid)
167
+ console.log(`foreman: resume this builder with --resume ${sid}`);
168
+ process.exit(result.outcome === "needs-human" ? 2 : 0);
169
+ }
170
+ catch (err) {
171
+ await builder.close().catch(() => { });
172
+ log.write("error", { message: String(err) });
173
+ fail(`run failed: ${err instanceof Error ? err.message : String(err)}`);
174
+ }
175
+ });
176
+ program
177
+ .command("status")
178
+ .description("Summarize the most recent foreman run for a project.")
179
+ .argument("<project>", "path to the project directory")
180
+ .action((project) => {
181
+ const dir = join(resolve(project), ".foreman");
182
+ if (!existsSync(dir))
183
+ fail(`no foreman runs found under ${dir}`);
184
+ const logs = readdirSync(dir)
185
+ .filter((f) => f.endsWith(".jsonl"))
186
+ .sort();
187
+ if (logs.length === 0)
188
+ fail(`no foreman runs found under ${dir}`);
189
+ const latest = join(dir, logs[logs.length - 1]);
190
+ const records = readFileSync(latest, "utf8")
191
+ .split("\n")
192
+ .filter(Boolean)
193
+ .map((line) => JSON.parse(line));
194
+ const steps = records.filter((r) => r.event === "step");
195
+ const escalations = records.filter((r) => r.event === "escalation");
196
+ const batchEnd = records.reverse().find((r) => r.event === "batch-end");
197
+ console.log(`foreman: latest run ${logs[logs.length - 1]}`);
198
+ console.log(`foreman: ${steps.length} step record(s), ${escalations.length} escalation(s)`);
199
+ if (batchEnd) {
200
+ console.log(`foreman: outcome — ${batchEnd.outcome} (${batchEnd.completed}/${batchEnd.requested})`);
201
+ if (batchEnd.detail)
202
+ console.log(`foreman: ${batchEnd.detail}`);
203
+ }
204
+ else {
205
+ console.log("ai-foreman: run is still in progress or did not finish");
206
+ }
207
+ for (const esc of escalations) {
208
+ console.log(` escalated: ${esc.tool} — ${esc.reason}`);
209
+ }
210
+ });
211
+ program
212
+ .command("doctor")
213
+ .description("Check Foreman, agent CLIs, config, and optional ticket tracker readiness.")
214
+ .argument("[project]", "path to the project directory", ".")
215
+ .action((project) => {
216
+ const cwd = resolve(project);
217
+ let errors = 0;
218
+ const report = (ok, label, detail) => {
219
+ console.log(`${ok ? "ok" : "!!"} ${label}${detail ? ` — ${detail}` : ""}`);
220
+ if (!ok)
221
+ errors++;
222
+ };
223
+ const warn = (label, detail) => {
224
+ console.log(`-- ${label}${detail ? ` — ${detail}` : ""}`);
225
+ };
226
+ report(Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10) >= 20, "node >=20", process.version);
227
+ report(Boolean(PACKAGE_VERSION), "foreman package version", PACKAGE_VERSION);
228
+ report(existsSync(cwd), "project directory exists", cwd);
229
+ if (!existsSync(cwd))
230
+ process.exit(1);
231
+ try {
232
+ loadConfig(join(cwd, "foreman.yaml"));
233
+ report(true, "foreman.yaml", existsSync(join(cwd, "foreman.yaml")) ? "valid" : "not present, using defaults");
234
+ }
235
+ catch (err) {
236
+ report(false, "foreman.yaml", err instanceof Error ? err.message : String(err));
237
+ }
238
+ const claude = commandVersion("claude");
239
+ if (claude.ok)
240
+ warn("claude CLI found", claude.detail);
241
+ else
242
+ warn("claude CLI not found", "Claude adapter may still work through the SDK if credentials are configured");
243
+ const codex = commandVersion("codex");
244
+ if (codex.ok)
245
+ warn("codex CLI found", codex.detail);
246
+ else
247
+ warn("codex CLI not found", "required only for --agent codex");
248
+ if (isTicketsInitialized(cwd)) {
249
+ try {
250
+ const result = cmdValidate(cwd);
251
+ report(result.clean, ".tickets validation", result.clean ? "clean" : `${result.issues.length} issue(s)`);
252
+ }
253
+ catch (err) {
254
+ report(false, ".tickets validation", err instanceof Error ? err.message : String(err));
255
+ }
256
+ }
257
+ else {
258
+ warn(".tickets", "not initialized; start will run in plain mode");
259
+ }
260
+ process.exit(errors === 0 ? 0 : 1);
261
+ });
262
+ // pnpm passes its `--` separator through to the script; strip it so Commander
263
+ // sees the subcommand args correctly.
264
+ const argv = [...process.argv];
265
+ if (argv[2] === "--")
266
+ argv.splice(2, 1);
267
+ program.parseAsync(argv).catch((err) => fail(String(err)));
268
+ function fail(message) {
269
+ console.error(`foreman: ${message}`);
270
+ process.exit(1);
271
+ }
272
+ function commandVersion(command) {
273
+ const result = spawnSync(command, ["--version"], {
274
+ encoding: "utf8",
275
+ timeout: 3000,
276
+ });
277
+ if (result.error)
278
+ return { ok: false, detail: result.error.message };
279
+ if (result.status !== 0)
280
+ return { ok: false, detail: result.stderr.trim() };
281
+ return { ok: true, detail: (result.stdout.trim() || result.stderr.trim()).slice(0, 120) };
282
+ }
283
+ /** Read the most recent batch-end sessionId from the last .foreman log, for auto-resume. */
284
+ function findLastSessionId(dir) {
285
+ if (!existsSync(dir))
286
+ return undefined;
287
+ const logs = readdirSync(dir).filter((f) => f.endsWith(".jsonl")).sort();
288
+ if (logs.length === 0)
289
+ return undefined;
290
+ const lines = readFileSync(join(dir, logs[logs.length - 1]), "utf8")
291
+ .split("\n")
292
+ .filter(Boolean);
293
+ for (let i = lines.length - 1; i >= 0; i--) {
294
+ const r = JSON.parse(lines[i]);
295
+ if (r.event === "batch-end" && typeof r.sessionId === "string") {
296
+ return r.sessionId;
297
+ }
298
+ }
299
+ return undefined;
300
+ }
package/dist/log.js ADDED
@@ -0,0 +1,18 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ /** Append-only JSONL logger. One line per record, plus an echo to the console. */
4
+ export class Log {
5
+ path;
6
+ constructor(path) {
7
+ this.path = path;
8
+ mkdirSync(dirname(path), { recursive: true });
9
+ }
10
+ write(event, fields) {
11
+ const record = {
12
+ ts: new Date().toISOString(),
13
+ event,
14
+ ...fields,
15
+ };
16
+ appendFileSync(this.path, JSON.stringify(record) + "\n");
17
+ }
18
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * STEP_STATUS protocol strings. Extracted from foreman.ts so tests can import
3
+ * them without pulling in @clack/prompts (which requires Node >=20 for styleText).
4
+ */
5
+ export const MARKER_SPEC = `End EVERY turn with exactly one marker line as the LAST line, nothing after it:
6
+ STEP_STATUS: done | ticket="T001" summary="what you just did" next="the next ticket or step"
7
+ STEP_STATUS: blocked | ticket="T001" reason="why you cannot proceed"
8
+ STEP_STATUS: plan_complete | ticket="T001" summary="what you just did"
9
+ STEP_STATUS: needs_input | question="your question for the user" choices="Option A|Option B|Option C"
10
+ Use "done" after finishing a ticket or step when more remain, "plan_complete" after
11
+ the final ticket or step, "blocked" if you cannot proceed without help, and "needs_input"
12
+ if you need the user to make a decision before continuing.
13
+ Always include ticket="<ticket-id>" in done/blocked/plan_complete markers when working from a ticket queue.
14
+ After "done" or "plan_complete", foreman may run a QA pass on your work — expect a
15
+ follow-up instruction asking you to triple-check accuracy, tests, and ticket satisfaction.`;
16
+ export const QA_MARKER_SPEC = `End EVERY turn with exactly one marker line as the LAST line, nothing after it:
17
+ STEP_STATUS: qa_pass | summary="confirmed everything checks out"
18
+ STEP_STATUS: qa_fail | issues="bullet-list of concrete problems found"
19
+ STEP_STATUS: blocked | reason="why QA itself cannot proceed"
20
+ STEP_STATUS: needs_input | question="..." choices="..."`;
package/dist/notify.js ADDED
@@ -0,0 +1,49 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ function isWSL() {
4
+ try {
5
+ return readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft");
6
+ }
7
+ catch {
8
+ return false;
9
+ }
10
+ }
11
+ /** Strip characters that break notification string literals. */
12
+ function safe(s) {
13
+ return s.replace(/["\\\n\r]/g, " ").slice(0, 120);
14
+ }
15
+ function xmlEsc(s) {
16
+ return s
17
+ .replace(/&/g, "&amp;")
18
+ .replace(/</g, "&lt;")
19
+ .replace(/>/g, "&gt;")
20
+ .replace(/"/g, "&quot;");
21
+ }
22
+ /**
23
+ * Fire a non-interactive desktop notification. Always best-effort — if the
24
+ * platform tool is unavailable the error is silently swallowed.
25
+ *
26
+ * macOS : osascript (built-in)
27
+ * Linux : notify-send (libnotify)
28
+ * WSL : powershell.exe Windows toast (no extra install required)
29
+ */
30
+ export function fireNotification(title, body) {
31
+ try {
32
+ if (process.platform === "darwin") {
33
+ spawnSync("osascript", ["-e", `display notification "${safe(body)}" with title "${safe(title)}"`], { timeout: 3000 });
34
+ }
35
+ else if (isWSL()) {
36
+ const xml = `<toast><visual><binding template="ToastGeneric"><text>${xmlEsc(title)}</text><text>${xmlEsc(body)}</text></binding></visual></toast>`;
37
+ const ps = `$d=New-Object Windows.Data.Xml.Dom.XmlDocument;$d.LoadXml(${JSON.stringify(xml)});$t=New-Object Windows.UI.Notifications.ToastNotification($d);[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Foreman').Show($t)`;
38
+ spawnSync("powershell.exe", ["-NonInteractive", "-WindowStyle", "Hidden", "-Command", ps], {
39
+ timeout: 5000,
40
+ });
41
+ }
42
+ else if (process.platform === "linux") {
43
+ spawnSync("notify-send", [title, body], { timeout: 3000 });
44
+ }
45
+ }
46
+ catch {
47
+ // best-effort — never crash the run
48
+ }
49
+ }
@@ -0,0 +1,84 @@
1
+ import { resolve, isAbsolute } from "node:path";
2
+ /** Tools that write to disk — allowed only inside the worktree. */
3
+ const FILE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
4
+ /** Shell operators we split on to vet each segment of a chained command. */
5
+ const SHELL_SPLIT = /\s*(?:&&|\|\||;|\|)\s*/;
6
+ /** Shell features that make a prefix allow-list too easy to bypass. */
7
+ const ESCALATE_SHELL_SYNTAX = [
8
+ { pattern: /\s\d?>|\s>>|\s</, reason: "shell redirection is not auto-approved" },
9
+ { pattern: /\$\(|`/, reason: "shell substitution is not auto-approved" },
10
+ ];
11
+ /**
12
+ * Rules-based permission classifier. No LLM: routine requests are auto-approved,
13
+ * anything risky or unrecognized escalates to the human (fail safe).
14
+ */
15
+ export class PermissionPolicy {
16
+ config;
17
+ cwd;
18
+ constructor(config, cwd) {
19
+ this.config = config;
20
+ this.cwd = cwd;
21
+ }
22
+ classify(req) {
23
+ const { toolName, input } = req;
24
+ if (this.config.escalateTools.includes(toolName)) {
25
+ return { decision: "escalate", reason: `tool ${toolName} always escalates` };
26
+ }
27
+ if (toolName === "Bash") {
28
+ return this.classifyBash(String(input.command ?? ""));
29
+ }
30
+ if (FILE_TOOLS.has(toolName)) {
31
+ const target = this.filePath(input);
32
+ if (target && !this.insideCwd(target)) {
33
+ return {
34
+ decision: "escalate",
35
+ reason: `writes outside the worktree: ${target}`,
36
+ };
37
+ }
38
+ if (this.config.allowTools.includes(toolName)) {
39
+ return { decision: "allow", reason: `${toolName} inside the worktree` };
40
+ }
41
+ }
42
+ if (this.config.allowTools.includes(toolName)) {
43
+ return { decision: "allow", reason: `${toolName} is allow-listed` };
44
+ }
45
+ return { decision: "escalate", reason: `tool ${toolName} not recognized` };
46
+ }
47
+ classifyBash(command) {
48
+ // Always-escalate list is checked first against the full command string —
49
+ // a dangerous pattern anywhere in a chained command escalates the whole call.
50
+ const hitEscalate = this.config.escalateBash.find((p) => command.includes(p));
51
+ if (hitEscalate) {
52
+ return { decision: "escalate", reason: `command matches "${hitEscalate}"` };
53
+ }
54
+ const syntax = ESCALATE_SHELL_SYNTAX.find((s) => s.pattern.test(command));
55
+ if (syntax) {
56
+ return { decision: "escalate", reason: syntax.reason };
57
+ }
58
+ const segments = command.split(SHELL_SPLIT).map((s) => s.trim()).filter(Boolean);
59
+ if (segments.length === 0) {
60
+ return { decision: "escalate", reason: "empty bash command" };
61
+ }
62
+ for (const segment of segments) {
63
+ const allow = this.config.allowBash.find((p) => this.matchesAllowedPrefix(segment, p));
64
+ if (!allow) {
65
+ return { decision: "escalate", reason: `command segment is not allow-listed: ${segment}` };
66
+ }
67
+ }
68
+ return { decision: "allow", reason: "all command segments are allow-listed" };
69
+ }
70
+ matchesAllowedPrefix(command, prefix) {
71
+ if (prefix.endsWith(" "))
72
+ return command.startsWith(prefix);
73
+ return command === prefix || command.startsWith(prefix + " ");
74
+ }
75
+ filePath(input) {
76
+ const p = input.file_path ?? input.path ?? input.notebook_path;
77
+ return typeof p === "string" ? p : undefined;
78
+ }
79
+ insideCwd(target) {
80
+ const abs = isAbsolute(target) ? target : resolve(this.cwd, target);
81
+ const root = resolve(this.cwd);
82
+ return abs === root || abs.startsWith(root + "/");
83
+ }
84
+ }
package/dist/roles.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 3-tier role bundle loader. Returns the composed system text + metadata for a
3
+ * given role, trying each tier in order:
4
+ *
5
+ * 1. `.rafi/compiled/<role>/` in the target repo — written by `rafi compile`.
6
+ * 2. Library defaults from `special-agents` — the prebuilt default bundle.
7
+ * 3. Hardcoded fallback — empty system text, no skills (today's behavior).
8
+ *
9
+ * `libraryGetAgent` can be injected for tests; pass `null` to force tier 3.
10
+ */
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { getAgent } from "special-agents";
14
+ function libraryGetter(role) {
15
+ const a = getAgent(role);
16
+ return { system: a.system, skills: a.skills, model: a.model, effort: a.effort };
17
+ }
18
+ export function loadRoleBundle(role, opts = {}) {
19
+ // Tier 1: compiled bundle in the target repo
20
+ if (opts.projectDir) {
21
+ const base = join(opts.projectDir, ".rafi", "compiled", role);
22
+ const sysPath = join(base, "system.md");
23
+ const metaPath = join(base, "meta.json");
24
+ if (existsSync(sysPath) && existsSync(metaPath)) {
25
+ const system = readFileSync(sysPath, "utf8");
26
+ const meta = JSON.parse(readFileSync(metaPath, "utf8"));
27
+ return {
28
+ system,
29
+ skills: meta.skills ?? [],
30
+ model: meta.model ?? null,
31
+ effort: meta.effort ?? null,
32
+ source: "compiled",
33
+ };
34
+ }
35
+ }
36
+ // Tier 2: library defaults (special-agents)
37
+ const getter = opts.libraryGetAgent !== undefined ? opts.libraryGetAgent : libraryGetter;
38
+ if (getter !== null) {
39
+ try {
40
+ const b = getter(role);
41
+ return { ...b, source: "library" };
42
+ }
43
+ catch {
44
+ // fall through to tier 3
45
+ }
46
+ }
47
+ // Tier 3: hardcoded fallback — preserves behavior from before Phase 5
48
+ return { system: "", skills: [], model: null, effort: null, source: "fallback" };
49
+ }
@@ -0,0 +1,22 @@
1
+ export function resolveBlockers(ticket, states) {
2
+ const unresolvedDeps = ticket.depends_on.filter((depId) => {
3
+ const dep = states.get(depId);
4
+ return dep?.status !== "done";
5
+ });
6
+ const state = states.get(ticket.id);
7
+ const explicitBlockers = state
8
+ ? JSON.parse(state.blocked_by_json || "[]")
9
+ : [];
10
+ return [...new Set([...unresolvedDeps, ...explicitBlockers])];
11
+ }
12
+ export function computeDisplayStatus(storedStatus, blockedBy) {
13
+ if (storedStatus === "done")
14
+ return "done";
15
+ if (storedStatus === "canceled")
16
+ return "canceled";
17
+ if (storedStatus === "in_progress")
18
+ return "in_progress";
19
+ if (blockedBy.length > 0 || storedStatus === "blocked")
20
+ return "blocked";
21
+ return "next";
22
+ }