ai-foreman 1.4.0 → 1.5.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.
@@ -77,6 +77,7 @@ export class CodexAdapter {
77
77
  const args = [
78
78
  "exec",
79
79
  "--json",
80
+ "--skip-git-repo-check",
80
81
  "--sandbox",
81
82
  this.opts.sandboxMode ?? "workspace-write",
82
83
  "-C",
@@ -43,6 +43,7 @@ export interface RoleInstructionRunResult {
43
43
  runtime: AgentRuntime;
44
44
  model?: string;
45
45
  effort?: EffortLevel;
46
+ sessionId?: string;
46
47
  logPath: string;
47
48
  roleBundle: RoleBundle;
48
49
  skills: string[];
package/dist/agentRun.js CHANGED
@@ -133,6 +133,10 @@ export async function runRoleInstruction(opts) {
133
133
  if (opts.logEvent) {
134
134
  log.write(opts.logEvent, {
135
135
  role: opts.role,
136
+ runtime,
137
+ model,
138
+ effort,
139
+ sessionId: builder.sessionId(),
136
140
  statusKind: turn.status.kind,
137
141
  summary: turn.status.summary,
138
142
  reason: turn.status.reason,
@@ -140,7 +144,7 @@ export async function runRoleInstruction(opts) {
140
144
  isError: turn.result.isError,
141
145
  });
142
146
  }
143
- return { turn, runtime, model, effort, logPath, roleBundle, skills };
147
+ return { turn, runtime, model, effort, sessionId: builder.sessionId(), logPath, roleBundle, skills };
144
148
  }
145
149
  catch (err) {
146
150
  await builder.close().catch(() => { });
@@ -13,7 +13,8 @@ import { validateDocsRoot } from "../tickets/config.js";
13
13
  import { importExternalSources, importFromMarkdown, validateExternalSourceAccess } from "../tickets/importer.js";
14
14
  import { formatValidationIssues } from "../tickets/validate.js";
15
15
  import { assertEffortLevel, createRoleBuilder, makeLogPath, } from "../agentRun.js";
16
- import { DEFAULT_TICKET_SETUP, defaultAppName, ensureRafiConfigForTicketSetup, externalSources, hasTicketSetupConfig, loadTicketSetupConfig, loadTicketSetupConfigWithDefaults, localSourcePaths, mergeTicketSetup, recommendedBuildDefaults, saveTicketSetupConfig, } from "../tickets/setupConfig.js";
16
+ import { DEFAULT_TICKET_SETUP, configuredPlanningSources, defaultAppName, ensureRafiConfigForTicketSetup, externalSources, hasTicketSetupConfig, loadTicketSetupConfig, loadTicketSetupConfigWithDefaults, localSourcePaths, mergeTicketSetup, recommendedBuildDefaults, saveTicketSetupConfig, } from "../tickets/setupConfig.js";
17
+ import { checkpointInterview, completeInterview, createInterviewRecord, failInterview, } from "../interviews.js";
17
18
  function fail(msg) {
18
19
  console.error(`foreman tickets: ${msg}`);
19
20
  process.exit(1);
@@ -138,72 +139,113 @@ async function cmdSetupInitCli(opts) {
138
139
  const dir = cwd(opts);
139
140
  if (!existsSync(dir))
140
141
  fail(`project directory not found: ${dir}`);
142
+ let interview = beginTicketSetupInterview(dir, "tickets-setup-init", opts);
141
143
  if (hasTicketSetupConfig(dir)) {
142
144
  console.log("foreman tickets setup: existing setup found; opening setup:update");
143
145
  await cmdSetupUpdateCli(opts);
146
+ if (interview)
147
+ completeInterview(dir, interview);
144
148
  return;
145
149
  }
146
- const answers = await collectTicketSetup(dir, opts, undefined);
147
- ensureRafiConfigForTicketSetup(dir, {
148
- appName: opts.appName ?? defaultAppName(dir),
149
- docsRoot: opts.docsRoot,
150
- targets: parseRuntimeTargets(opts.runtime),
151
- });
152
- saveTicketSetupConfig(dir, answers, {
153
- appName: opts.appName ?? defaultAppName(dir),
154
- docsRoot: opts.docsRoot,
155
- targets: parseRuntimeTargets(opts.runtime),
156
- });
157
- console.log(`foreman tickets setup: saved ticket setup in ${join(dir, "rafi-config.yaml")}`);
158
- await validateConfiguredSourcesIfRequested(answers, Boolean(opts.skipAccessCheck));
159
- if (!isTicketsInitialized(dir)) {
160
- cmdInit(dir, {
150
+ try {
151
+ const answers = await collectTicketSetup(dir, opts, undefined);
152
+ if (interview)
153
+ interview = checkpointInterview(dir, interview, { checkpoint: "save-setup", answers: { setup: answers } });
154
+ ensureRafiConfigForTicketSetup(dir, {
161
155
  appName: opts.appName ?? defaultAppName(dir),
162
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
163
156
  docsRoot: opts.docsRoot,
157
+ targets: parseRuntimeTargets(opts.runtime),
164
158
  });
165
- console.log("foreman tickets setup: initialized .tickets/");
166
- }
167
- if (shouldPrompt(opts)) {
168
- const populate = await confirm({
169
- message: "Run ticket population now?",
170
- initialValue: answers.sources.length > 0,
159
+ saveTicketSetupConfig(dir, answers, {
160
+ appName: opts.appName ?? defaultAppName(dir),
161
+ docsRoot: opts.docsRoot,
162
+ targets: parseRuntimeTargets(opts.runtime),
171
163
  });
172
- if (isCancel(populate))
173
- process.exit(0);
174
- if (populate) {
175
- await cmdPopulateCli({ project: dir, yes: true });
164
+ console.log(`foreman tickets setup: saved ticket setup in ${join(dir, "rafi-config.yaml")}`);
165
+ await validateConfiguredSourcesIfRequested(answers, Boolean(opts.skipAccessCheck));
166
+ if (!isTicketsInitialized(dir)) {
167
+ if (interview)
168
+ interview = checkpointInterview(dir, interview, { checkpoint: "initialize-tracker" });
169
+ cmdInit(dir, {
170
+ appName: opts.appName ?? defaultAppName(dir),
171
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
172
+ docsRoot: opts.docsRoot,
173
+ });
174
+ console.log("foreman tickets setup: initialized .tickets/");
175
+ }
176
+ if (shouldPrompt(opts)) {
177
+ if (interview)
178
+ interview = checkpointInterview(dir, interview, { checkpoint: "populate-confirmation" });
179
+ const populate = await confirm({
180
+ message: "Run ticket population now?",
181
+ initialValue: answers.sources.length > 0,
182
+ });
183
+ if (isCancel(populate))
184
+ return;
185
+ if (populate) {
186
+ await cmdPopulateCli({ project: dir, yes: true });
187
+ }
188
+ }
189
+ else {
190
+ console.log(`foreman tickets setup: next — run \`foreman tickets populate --project ${shellQuote(dir)}\``);
176
191
  }
192
+ if (interview)
193
+ completeInterview(dir, interview);
177
194
  }
178
- else {
179
- console.log(`foreman tickets setup: next — run \`foreman tickets populate --project ${shellQuote(dir)}\``);
195
+ catch (error) {
196
+ if (interview)
197
+ failInterview(dir, interview, interview.checkpoint, error);
198
+ throw error;
180
199
  }
181
200
  }
182
201
  async function cmdSetupUpdateCli(opts) {
183
202
  const dir = cwd(opts);
184
203
  if (!existsSync(dir))
185
204
  fail(`project directory not found: ${dir}`);
186
- const current = loadTicketSetupConfigWithDefaults(dir);
187
- const patch = await collectTicketSetupPatch(dir, opts, current);
188
- const next = mergeTicketSetup(current, patch);
189
- ensureRafiConfigForTicketSetup(dir, {
190
- appName: opts.appName ?? defaultAppName(dir),
191
- docsRoot: opts.docsRoot,
192
- targets: parseRuntimeTargets(opts.runtime),
193
- });
194
- saveTicketSetupConfig(dir, next, {
195
- appName: opts.appName ?? defaultAppName(dir),
196
- docsRoot: opts.docsRoot,
197
- targets: parseRuntimeTargets(opts.runtime),
198
- });
199
- console.log(`foreman tickets setup: updated ticket setup in ${join(dir, "rafi-config.yaml")}`);
200
- await validateConfiguredSourcesIfRequested(next, Boolean(opts.skipAccessCheck));
201
- if (isTicketsInitialized(dir)) {
202
- const rows = cmdQueue(dir, 1);
203
- if (rows.length === 0) {
204
- console.log(`foreman tickets setup: queue is empty; run \`foreman tickets populate --project ${shellQuote(dir)}\` to import or refresh tickets.`);
205
+ let interview = beginTicketSetupInterview(dir, "tickets-setup-update", opts);
206
+ try {
207
+ const current = loadTicketSetupConfigWithDefaults(dir);
208
+ const patch = await collectTicketSetupPatch(dir, opts, current);
209
+ const next = mergeTicketSetup(current, patch);
210
+ if (interview)
211
+ interview = checkpointInterview(dir, interview, { checkpoint: "save-setup", answers: { setup: next } });
212
+ ensureRafiConfigForTicketSetup(dir, {
213
+ appName: opts.appName ?? defaultAppName(dir),
214
+ docsRoot: opts.docsRoot,
215
+ targets: parseRuntimeTargets(opts.runtime),
216
+ });
217
+ saveTicketSetupConfig(dir, next, {
218
+ appName: opts.appName ?? defaultAppName(dir),
219
+ docsRoot: opts.docsRoot,
220
+ targets: parseRuntimeTargets(opts.runtime),
221
+ });
222
+ console.log(`foreman tickets setup: updated ticket setup in ${join(dir, "rafi-config.yaml")}`);
223
+ await validateConfiguredSourcesIfRequested(next, Boolean(opts.skipAccessCheck));
224
+ if (isTicketsInitialized(dir)) {
225
+ const rows = cmdQueue(dir, 1);
226
+ if (rows.length === 0) {
227
+ console.log(`foreman tickets setup: queue is empty; run \`foreman tickets populate --project ${shellQuote(dir)}\` to import or refresh tickets.`);
228
+ }
205
229
  }
230
+ if (interview)
231
+ completeInterview(dir, interview);
206
232
  }
233
+ catch (error) {
234
+ if (interview)
235
+ failInterview(dir, interview, interview.checkpoint, error);
236
+ throw error;
237
+ }
238
+ }
239
+ function beginTicketSetupInterview(projectDir, workflow, opts) {
240
+ if (!shouldPrompt(opts))
241
+ return undefined;
242
+ const record = createInterviewRecord({
243
+ workflow,
244
+ invocation: { projectDir, ...opts },
245
+ checkpoint: "setup-section",
246
+ outputs: ["rafi-config.yaml", ".tickets/config.yaml"],
247
+ });
248
+ return checkpointInterview(projectDir, record, { checkpoint: "setup-section" });
207
249
  }
208
250
  async function collectTicketSetup(dir, opts, current) {
209
251
  const patch = await collectTicketSetupPatch(dir, opts, current ?? DEFAULT_TICKET_SETUP);
@@ -229,8 +271,11 @@ async function collectTicketSetupPatch(dir, opts, current) {
229
271
  buildPatch.auto_merge_timeout_minutes = parseOptionalPositiveInteger(opts.autoMergeTimeoutMinutes, "--auto-merge-timeout-minutes");
230
272
  }
231
273
  if (!shouldPrompt(opts)) {
274
+ const prefill = current.sources.length === 0
275
+ ? configuredPlanningSources(dir).map((path) => ({ type: "local", paths: [path] }))
276
+ : current.sources;
232
277
  return {
233
- sources: nonInteractiveSources.length > 0 ? nonInteractiveSources : current.sources,
278
+ sources: nonInteractiveSources.length > 0 ? nonInteractiveSources : prefill,
234
279
  populate: populatePatch,
235
280
  build: Object.keys(buildPatch).length > 0 ? buildPatch : {},
236
281
  };
@@ -246,7 +291,12 @@ async function collectTicketSetupPatch(dir, opts, current) {
246
291
  });
247
292
  if (isCancel(section))
248
293
  process.exit(0);
249
- let sources = nonInteractiveSources.length > 0 ? nonInteractiveSources : current.sources;
294
+ // Do not silently persist planning hints over a completed ticket setup. They
295
+ // are only the initial local-source suggestion for a fresh setup.
296
+ const planningPrefill = current.sources.length === 0
297
+ ? configuredPlanningSources(dir).map((path) => ({ type: "local", paths: [path] }))
298
+ : current.sources;
299
+ let sources = nonInteractiveSources.length > 0 ? nonInteractiveSources : planningPrefill;
250
300
  if (section === "sources" || section === "all") {
251
301
  sources = await promptTicketSources(dir, current.sources);
252
302
  }
@@ -0,0 +1,68 @@
1
+ export declare const INTERVIEW_STATE_VERSION = 1;
2
+ export declare const INTERVIEW_DIRECTORY = ".rafi/interviews";
3
+ export type InterviewWorkflow = "create" | "plan" | "tickets-setup-init" | "tickets-setup-update";
4
+ export type InterviewStatus = "in_progress" | "needs_review" | "completed" | "incompatible";
5
+ export interface InterviewRuntimeState {
6
+ runtime?: "claude" | "codex";
7
+ model?: string;
8
+ sessionId?: string;
9
+ /** True when a session id could not be resumed and a fresh session is needed. */
10
+ continuityLost?: boolean;
11
+ }
12
+ export interface InterviewOutputFingerprint {
13
+ path: string;
14
+ sha256: string | null;
15
+ }
16
+ export interface InterviewFailure {
17
+ checkpoint: string;
18
+ summary: string;
19
+ at: string;
20
+ }
21
+ export interface InterviewRecord {
22
+ version: number;
23
+ id: string;
24
+ workflow: InterviewWorkflow;
25
+ status: InterviewStatus;
26
+ /** Arguments/context required to re-enter the runner. No environment secrets. */
27
+ invocation: Record<string, unknown>;
28
+ answers: Record<string, unknown>;
29
+ checkpoint: string;
30
+ runtime: InterviewRuntimeState;
31
+ outputs: InterviewOutputFingerprint[];
32
+ createdAt: string;
33
+ updatedAt: string;
34
+ completedAt?: string;
35
+ failure?: InterviewFailure;
36
+ }
37
+ export interface InterviewRecordProblem {
38
+ path: string;
39
+ reason: string;
40
+ }
41
+ export interface InterviewReadResult {
42
+ records: InterviewRecord[];
43
+ problems: InterviewRecordProblem[];
44
+ }
45
+ export declare function interviewDirectory(projectDir: string): string;
46
+ export declare function createInterviewRecord(input: {
47
+ workflow: InterviewWorkflow;
48
+ invocation: Record<string, unknown>;
49
+ answers?: Record<string, unknown>;
50
+ checkpoint: string;
51
+ outputs?: string[];
52
+ now?: Date;
53
+ }): InterviewRecord;
54
+ /** Ensure ignored local state exists without modifying unrelated gitignore text. */
55
+ export declare function ensureInterviewGitignore(projectDir: string): void;
56
+ export declare function saveInterviewRecord(projectDir: string, record: InterviewRecord, now?: Date): InterviewRecord;
57
+ export declare function checkpointInterview(projectDir: string, record: InterviewRecord, update: Partial<Pick<InterviewRecord, "answers" | "checkpoint" | "runtime" | "outputs" | "invocation" | "status">>): InterviewRecord;
58
+ export declare function failInterview(projectDir: string, record: InterviewRecord, checkpoint: string, error: unknown): InterviewRecord;
59
+ export declare function completeInterview(projectDir: string, record: InterviewRecord, now?: Date): InterviewRecord;
60
+ export declare function readInterviewRecords(projectDir: string): InterviewReadResult;
61
+ export declare function unfinishedInterviews(projectDir: string): InterviewRecord[];
62
+ export declare function findInterviewRecord(projectDir: string, id: string): InterviewRecord | undefined;
63
+ export declare function discardInterview(projectDir: string, id: string): boolean;
64
+ /** Remove only completed records past retention; malformed/incompatible files stay for explicit discard. */
65
+ export declare function pruneCompletedInterviews(projectDir: string, now?: Date): string[];
66
+ export declare function fingerprintOutputs(projectDir: string, paths: string[]): InterviewOutputFingerprint[];
67
+ export declare function outputsChanged(projectDir: string, fingerprints: InterviewOutputFingerprint[]): string[];
68
+ export declare function redactFailure(error: unknown): string;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Durable, local state for commands that ask a human questions. This lives in
3
+ * ai-foreman (rather than a CLI package) because `rafi tickets` is implemented
4
+ * there too. Records deliberately contain decisions and small diagnostics,
5
+ * never a transcript or command output.
6
+ */
7
+ import { createHash, randomUUID } from "node:crypto";
8
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
9
+ import { join, relative, resolve } from "node:path";
10
+ export const INTERVIEW_STATE_VERSION = 1;
11
+ export const INTERVIEW_DIRECTORY = ".rafi/interviews";
12
+ const MAX_FAILURE_LENGTH = 1_000;
13
+ const COMPLETE_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
14
+ export function interviewDirectory(projectDir) {
15
+ return join(resolve(projectDir), INTERVIEW_DIRECTORY);
16
+ }
17
+ export function createInterviewRecord(input) {
18
+ const stamp = (input.now ?? new Date()).toISOString();
19
+ return {
20
+ version: INTERVIEW_STATE_VERSION,
21
+ id: randomUUID(),
22
+ workflow: input.workflow,
23
+ status: "in_progress",
24
+ invocation: { ...input.invocation },
25
+ answers: { ...(input.answers ?? {}) },
26
+ checkpoint: input.checkpoint,
27
+ runtime: {},
28
+ outputs: fingerprintOutputs(String(input.invocation.projectDir ?? ""), input.outputs ?? []),
29
+ createdAt: stamp,
30
+ updatedAt: stamp,
31
+ };
32
+ }
33
+ /** Ensure ignored local state exists without modifying unrelated gitignore text. */
34
+ export function ensureInterviewGitignore(projectDir) {
35
+ const root = resolve(projectDir);
36
+ const path = join(root, ".gitignore");
37
+ const line = ".rafi/interviews/";
38
+ const current = existsSync(path) ? readFileSync(path, "utf8") : "";
39
+ if (current.split(/\r?\n/).some((entry) => entry.trim() === line))
40
+ return;
41
+ const prefix = current && !current.endsWith("\n") ? "\n" : "";
42
+ writeFileSync(path, `${current}${prefix}${line}\n`, "utf8");
43
+ }
44
+ export function saveInterviewRecord(projectDir, record, now = new Date()) {
45
+ validateRecord(record);
46
+ const directory = interviewDirectory(projectDir);
47
+ mkdirSync(directory, { recursive: true });
48
+ ensureInterviewGitignore(projectDir);
49
+ const next = { ...record, updatedAt: now.toISOString() };
50
+ const path = interviewRecordPath(projectDir, next.id);
51
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
52
+ writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
53
+ renameSync(temp, path);
54
+ return next;
55
+ }
56
+ export function checkpointInterview(projectDir, record, update) {
57
+ const next = {
58
+ ...record,
59
+ ...update,
60
+ answers: update.answers ? { ...update.answers } : record.answers,
61
+ runtime: update.runtime ? { ...update.runtime } : record.runtime,
62
+ invocation: update.invocation ? { ...update.invocation } : record.invocation,
63
+ outputs: update.outputs ? update.outputs.map((output) => ({ ...output })) : record.outputs,
64
+ };
65
+ return saveInterviewRecord(projectDir, next);
66
+ }
67
+ export function failInterview(projectDir, record, checkpoint, error) {
68
+ const at = new Date().toISOString();
69
+ return saveInterviewRecord(projectDir, {
70
+ ...record,
71
+ status: record.status === "needs_review" ? "needs_review" : "in_progress",
72
+ checkpoint,
73
+ failure: { checkpoint, at, summary: redactFailure(error) },
74
+ });
75
+ }
76
+ export function completeInterview(projectDir, record, now = new Date()) {
77
+ const stamp = now.toISOString();
78
+ return saveInterviewRecord(projectDir, {
79
+ ...record,
80
+ status: "completed",
81
+ completedAt: stamp,
82
+ failure: undefined,
83
+ }, now);
84
+ }
85
+ export function readInterviewRecords(projectDir) {
86
+ const directory = interviewDirectory(projectDir);
87
+ if (!existsSync(directory))
88
+ return { records: [], problems: [] };
89
+ const records = [];
90
+ const problems = [];
91
+ for (const name of readFileNames(directory)) {
92
+ const path = join(directory, name);
93
+ try {
94
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
95
+ const record = parseInterviewRecord(parsed);
96
+ records.push(record);
97
+ }
98
+ catch (error) {
99
+ problems.push({ path, reason: error instanceof Error ? error.message : String(error) });
100
+ }
101
+ }
102
+ records.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
103
+ return { records, problems };
104
+ }
105
+ export function unfinishedInterviews(projectDir) {
106
+ return readInterviewRecords(projectDir).records.filter((record) => record.status === "in_progress" || record.status === "needs_review" || record.status === "incompatible");
107
+ }
108
+ export function findInterviewRecord(projectDir, id) {
109
+ return readInterviewRecords(projectDir).records.find((record) => record.id === id || record.id.startsWith(id));
110
+ }
111
+ export function discardInterview(projectDir, id) {
112
+ const record = findInterviewRecord(projectDir, id);
113
+ if (!record)
114
+ return false;
115
+ rmSync(interviewRecordPath(projectDir, record.id));
116
+ return true;
117
+ }
118
+ /** Remove only completed records past retention; malformed/incompatible files stay for explicit discard. */
119
+ export function pruneCompletedInterviews(projectDir, now = new Date()) {
120
+ const removed = [];
121
+ for (const record of readInterviewRecords(projectDir).records) {
122
+ if (record.status !== "completed" || !record.completedAt)
123
+ continue;
124
+ if (now.getTime() - new Date(record.completedAt).getTime() <= COMPLETE_RETENTION_MS)
125
+ continue;
126
+ rmSync(interviewRecordPath(projectDir, record.id));
127
+ removed.push(record.id);
128
+ }
129
+ return removed;
130
+ }
131
+ export function fingerprintOutputs(projectDir, paths) {
132
+ if (!projectDir)
133
+ return paths.map((path) => ({ path, sha256: null }));
134
+ return paths.map((path) => ({ path, sha256: fingerprintFile(projectDir, path) }));
135
+ }
136
+ export function outputsChanged(projectDir, fingerprints) {
137
+ return fingerprints
138
+ .filter((fingerprint) => fingerprintFile(projectDir, fingerprint.path) !== fingerprint.sha256)
139
+ .map((fingerprint) => fingerprint.path);
140
+ }
141
+ export function redactFailure(error) {
142
+ const raw = error instanceof Error ? error.message : String(error);
143
+ return raw
144
+ .replace(/((?:api[_-]?key|token|password|secret)\s*[=:])\s*[^\s,;]+/gi, "$1<redacted>")
145
+ .replace(/\b(?:sk|rk|ghp|github_pat)_[A-Za-z0-9_-]+\b/g, "<redacted>")
146
+ .replace(/\s+/g, " ")
147
+ .slice(0, MAX_FAILURE_LENGTH);
148
+ }
149
+ function interviewRecordPath(projectDir, id) {
150
+ return join(interviewDirectory(projectDir), `${id}.json`);
151
+ }
152
+ function readFileNames(directory) {
153
+ return readdirSync(directory).filter((name) => name.endsWith(".json"));
154
+ }
155
+ function fingerprintFile(projectDir, path) {
156
+ const root = resolve(projectDir);
157
+ const absolute = resolve(root, path);
158
+ if (relative(root, absolute).startsWith(".."))
159
+ return null;
160
+ if (!existsSync(absolute))
161
+ return null;
162
+ return createHash("sha256").update(readFileSync(absolute)).digest("hex");
163
+ }
164
+ function parseInterviewRecord(value) {
165
+ if (!value || typeof value !== "object" || Array.isArray(value))
166
+ throw new Error("invalid interview record");
167
+ const record = value;
168
+ if (record.version !== INTERVIEW_STATE_VERSION) {
169
+ // Preserve it on disk. The caller can show it and require an explicit discard.
170
+ if (typeof record.id === "string" && typeof record.workflow === "string") {
171
+ return { ...record, status: "incompatible" };
172
+ }
173
+ throw new Error(`unsupported interview record version: ${String(record.version)}`);
174
+ }
175
+ validateRecord(record);
176
+ return record;
177
+ }
178
+ function validateRecord(record) {
179
+ if (record.version !== INTERVIEW_STATE_VERSION)
180
+ throw new Error("unsupported interview record version");
181
+ if (!record.id || !record.workflow || !record.checkpoint || !record.createdAt || !record.updatedAt) {
182
+ throw new Error("invalid interview record");
183
+ }
184
+ }
@@ -25,7 +25,7 @@ export function checkRuntimeReady(projectDir, runtime) {
25
25
  });
26
26
  }
27
27
  else {
28
- execFileSync("codex", ["exec", "-C", projectDir, "Return exactly OK"], {
28
+ execFileSync("codex", ["exec", "--skip-git-repo-check", "-C", projectDir, "Return exactly OK"], {
29
29
  cwd: projectDir,
30
30
  encoding: "utf8",
31
31
  stdio: ["ignore", "pipe", "pipe"],
@@ -65,6 +65,12 @@ export declare function mergeTicketSetup(current: TicketsSetupConfig | undefined
65
65
  build: Partial<TicketBuildSetupConfig>;
66
66
  }>): TicketsSetupConfig;
67
67
  export declare function localSourcePaths(setup: TicketsSetupConfig | undefined): string[];
68
+ /**
69
+ * Create records planning hints separately from completed ticket setup. Use
70
+ * them only as a setup prefill so `tickets.sources` remains the source of
71
+ * truth once the user has configured ticket ingestion.
72
+ */
73
+ export declare function configuredPlanningSources(projectDir: string): string[];
68
74
  export declare function externalSources(setup: TicketsSetupConfig | undefined): Extract<TicketSourceConfig, {
69
75
  type: "linear" | "jira";
70
76
  }>[];
@@ -106,6 +106,18 @@ export function localSourcePaths(setup) {
106
106
  .filter((source) => source.type === "local")
107
107
  .flatMap((source) => source.paths));
108
108
  }
109
+ /**
110
+ * Create records planning hints separately from completed ticket setup. Use
111
+ * them only as a setup prefill so `tickets.sources` remains the source of
112
+ * truth once the user has configured ticket ingestion.
113
+ */
114
+ export function configuredPlanningSources(projectDir) {
115
+ const raw = loadRafiConfigObject(projectDir);
116
+ const planning = raw?.planning;
117
+ if (!Array.isArray(planning?.sources))
118
+ return [];
119
+ return unique(planning.sources.filter((source) => typeof source === "string" && source.trim().length > 0).map((source) => source.trim()));
120
+ }
109
121
  export function externalSources(setup) {
110
122
  return (setup?.sources ?? []).filter((source) => source.type === "linear" || source.type === "jira");
111
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -21,6 +21,10 @@
21
21
  "types": "./dist/agentRun.d.ts",
22
22
  "default": "./dist/agentRun.js"
23
23
  },
24
+ "./interviews.js": {
25
+ "types": "./dist/interviews.d.ts",
26
+ "default": "./dist/interviews.js"
27
+ },
24
28
  "./cli/tickets.js": {
25
29
  "types": "./dist/cli/tickets.d.ts",
26
30
  "default": "./dist/cli/tickets.js"
@@ -69,7 +73,7 @@
69
73
  "@anthropic-ai/claude-agent-sdk": "^0.3.148"
70
74
  },
71
75
  "dependencies": {
72
- "special-agents": "0.5.0",
76
+ "special-agents": "0.6.0",
73
77
  "@clack/prompts": "^1.4.0",
74
78
  "ajv": "^8.20.0",
75
79
  "ajv-formats": "^3.0.1",