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.
- package/dist/adapters/codex.js +1 -0
- package/dist/agentRun.d.ts +1 -0
- package/dist/agentRun.js +5 -1
- package/dist/cli/tickets.js +100 -50
- package/dist/interviews.d.ts +68 -0
- package/dist/interviews.js +184 -0
- package/dist/runtimeAuth.js +1 -1
- package/dist/tickets/setupConfig.d.ts +6 -0
- package/dist/tickets/setupConfig.js +12 -0
- package/package.json +6 -2
package/dist/adapters/codex.js
CHANGED
package/dist/agentRun.d.ts
CHANGED
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(() => { });
|
package/dist/cli/tickets.js
CHANGED
|
@@ -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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
if (
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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 :
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/runtimeAuth.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|