ai-foreman 1.7.3 → 1.7.5
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/README.md +5 -0
- package/dist/activity.d.ts +63 -0
- package/dist/activity.js +244 -0
- package/dist/adapters/claude.d.ts +10 -1
- package/dist/adapters/claude.js +70 -6
- package/dist/adapters/codex.d.ts +1 -0
- package/dist/adapters/codex.js +65 -5
- package/dist/adapters/recovering.js +7 -1
- package/dist/adapters/types.d.ts +32 -0
- package/dist/agentRun.d.ts +5 -0
- package/dist/agentRun.js +7 -2
- package/dist/branch/github.js +2 -1
- package/dist/branch/gitlab.js +2 -1
- package/dist/branch/presentation.d.ts +17 -0
- package/dist/branch/presentation.js +38 -0
- package/dist/branch/resume.d.ts +2 -0
- package/dist/branch/resume.js +2 -0
- package/dist/branch/runner.js +44 -25
- package/dist/cli/events.js +24 -5
- package/dist/cli/runtimeAuthPrompt.js +5 -1
- package/dist/cli/start.js +36 -23
- package/dist/foreman.d.ts +7 -2
- package/dist/foreman.js +17 -1
- package/dist/index.js +3 -1
- package/dist/providerQuestions.d.ts +37 -0
- package/dist/providerQuestions.js +216 -0
- package/dist/qaReview.js +12 -9
- package/dist/qaSnapshot.d.ts +14 -0
- package/dist/qaSnapshot.js +117 -1
- package/dist/runtimeReadiness.js +5 -0
- package/dist/sources/sourceRegistry.js +13 -6
- package/dist/tickets/importer.js +13 -8
- package/dist/tickets/sourceFetch.js +19 -0
- package/package.json +11 -3
package/dist/foreman.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { select, isCancel } from "@clack/prompts";
|
|
2
2
|
import { MARKER_SPEC, QA_MARKER_SPEC } from "./markers.js";
|
|
3
|
+
import { countProviderQuestions, handleProviderQuestionTool } from "./providerQuestions.js";
|
|
3
4
|
import { fireNotification } from "./notify.js";
|
|
4
5
|
import { isTicketsInitialized, loadTicketsConfig } from "./tickets/config.js";
|
|
5
6
|
import { cmdUpdate, cmdComplete, cmdBlock, cmdImplementationQueue } from "./tickets/commands.js";
|
|
@@ -166,8 +167,23 @@ export function buildPlanningTurn(n, ticketsContent) {
|
|
|
166
167
|
return `${header}Before we begin, list the next ${n} ticket(s) or step(s) you plan to implement, in order. Be specific — reference ticket IDs or titles where applicable. Do not implement anything yet; output the numbered list only. Do not emit a STEP_STATUS marker on this turn.`;
|
|
167
168
|
}
|
|
168
169
|
/** Builds the permission callback: classify, log, allow or escalate. */
|
|
169
|
-
export function createPermissionHandler(policy, log) {
|
|
170
|
+
export function createPermissionHandler(policy, log, opts = {}) {
|
|
170
171
|
return async (req) => {
|
|
172
|
+
if (req.toolName === "AskUserQuestion")
|
|
173
|
+
opts.onProviderQuestion?.(req);
|
|
174
|
+
const providerQuestion = await handleProviderQuestionTool(req, {
|
|
175
|
+
interactive: opts.interactive ?? true,
|
|
176
|
+
onAnsweredQuestion: opts.onAnsweredQuestion,
|
|
177
|
+
});
|
|
178
|
+
if (providerQuestion) {
|
|
179
|
+
log.write("permission", {
|
|
180
|
+
tool: req.toolName,
|
|
181
|
+
decision: providerQuestion.behavior,
|
|
182
|
+
reason: "provider question prompt",
|
|
183
|
+
questionCount: countProviderQuestions(req.input),
|
|
184
|
+
});
|
|
185
|
+
return providerQuestion;
|
|
186
|
+
}
|
|
171
187
|
const verdict = policy.classify(req);
|
|
172
188
|
log.write("permission", {
|
|
173
189
|
tool: req.toolName,
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { buildTicketsCommand } from "./cli/tickets.js";
|
|
|
5
5
|
import { buildStartCommand } from "./cli/start.js";
|
|
6
6
|
import { buildStatusCommand } from "./cli/status.js";
|
|
7
7
|
import { buildDoctorCommand } from "./cli/doctor.js";
|
|
8
|
+
import { withActivityContext } from "./activity.js";
|
|
8
9
|
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))?.version;
|
|
9
10
|
const program = new Command();
|
|
10
11
|
program
|
|
@@ -20,7 +21,8 @@ program.addCommand(buildDoctorCommand());
|
|
|
20
21
|
const argv = [...process.argv];
|
|
21
22
|
if (argv[2] === "--")
|
|
22
23
|
argv.splice(2, 1);
|
|
23
|
-
|
|
24
|
+
const command = argv.slice(2).filter((arg) => !arg.startsWith("-")).slice(0, 3).join(" ") || "ai-foreman";
|
|
25
|
+
withActivityContext(command, () => program.parseAsync(argv)).catch((err) => {
|
|
24
26
|
console.error(`foreman: ${String(err)}`);
|
|
25
27
|
process.exit(1);
|
|
26
28
|
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { isCancel, multiselect, select, text } from "@clack/prompts";
|
|
2
|
+
import type { PermissionDecision, PermissionRequest } from "./adapters/types.js";
|
|
3
|
+
export interface PromptOption {
|
|
4
|
+
label: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
preview?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ProviderQuestion {
|
|
9
|
+
question: string;
|
|
10
|
+
header?: string;
|
|
11
|
+
options: PromptOption[];
|
|
12
|
+
multiSelect: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface ProviderQuestionPromptDeps {
|
|
15
|
+
select: typeof select;
|
|
16
|
+
multiselect: typeof multiselect;
|
|
17
|
+
text: typeof text;
|
|
18
|
+
isCancel: typeof isCancel;
|
|
19
|
+
}
|
|
20
|
+
export interface ProviderQuestionOptions {
|
|
21
|
+
interactive: boolean;
|
|
22
|
+
prompts?: ProviderQuestionPromptDeps;
|
|
23
|
+
/** Called only after a non-empty answer has been successfully collected. */
|
|
24
|
+
onAnsweredQuestion?: (event: AnsweredProviderQuestion) => void;
|
|
25
|
+
}
|
|
26
|
+
export interface AnsweredProviderQuestion {
|
|
27
|
+
toolName: "AskUserQuestion";
|
|
28
|
+
question: ProviderQuestion;
|
|
29
|
+
/** Number of readable questions presented by this provider tool call. */
|
|
30
|
+
questionCount: number;
|
|
31
|
+
answer: string;
|
|
32
|
+
}
|
|
33
|
+
export declare const GRILL_ME_STOP_CHOICE = "Stop questions and make the plan now";
|
|
34
|
+
export declare function handleProviderQuestionTool(req: PermissionRequest, opts: ProviderQuestionOptions): Promise<PermissionDecision | undefined>;
|
|
35
|
+
/** Machine-recognizable native grill-me question shape. */
|
|
36
|
+
export declare function isGrillMeProviderQuestion(event: AnsweredProviderQuestion): boolean;
|
|
37
|
+
export declare function countProviderQuestions(input: Record<string, unknown>): number;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { isCancel, multiselect, select, text } from "@clack/prompts";
|
|
2
|
+
const ASK_USER_QUESTION_TOOL = "AskUserQuestion";
|
|
3
|
+
const CUSTOM_VALUE = "__rafi_custom_response__";
|
|
4
|
+
export const GRILL_ME_STOP_CHOICE = "Stop questions and make the plan now";
|
|
5
|
+
export async function handleProviderQuestionTool(req, opts) {
|
|
6
|
+
if (req.toolName !== ASK_USER_QUESTION_TOOL)
|
|
7
|
+
return undefined;
|
|
8
|
+
if (!opts.interactive) {
|
|
9
|
+
return {
|
|
10
|
+
behavior: "deny",
|
|
11
|
+
interrupt: true,
|
|
12
|
+
message: "AskUserQuestion requires interactive input, but this Rafi run is non-interactive.",
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
const questions = parseQuestions(req.input);
|
|
16
|
+
if (questions.length === 0) {
|
|
17
|
+
return {
|
|
18
|
+
behavior: "deny",
|
|
19
|
+
interrupt: true,
|
|
20
|
+
message: "AskUserQuestion did not include any readable questions.",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const prompts = opts.prompts ?? { select, multiselect, text, isCancel };
|
|
24
|
+
const answers = recordFromUnknown(req.input.answers);
|
|
25
|
+
const annotations = annotationRecordFromUnknown(req.input.annotations);
|
|
26
|
+
for (const question of questions) {
|
|
27
|
+
const answered = await askOneQuestion(question, prompts, req.signal);
|
|
28
|
+
if (answered.cancelled) {
|
|
29
|
+
return {
|
|
30
|
+
behavior: "deny",
|
|
31
|
+
interrupt: true,
|
|
32
|
+
message: "User cancelled the provider question prompt.",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
answers[question.question] = answered.answer;
|
|
36
|
+
if (answered.annotation)
|
|
37
|
+
annotations[question.question] = answered.annotation;
|
|
38
|
+
if (answered.answer.trim()) {
|
|
39
|
+
opts.onAnsweredQuestion?.({
|
|
40
|
+
toolName: ASK_USER_QUESTION_TOOL,
|
|
41
|
+
question,
|
|
42
|
+
questionCount: questions.length,
|
|
43
|
+
answer: answered.answer,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const updatedInput = {
|
|
48
|
+
...req.input,
|
|
49
|
+
answers,
|
|
50
|
+
};
|
|
51
|
+
if (Object.keys(annotations).length > 0)
|
|
52
|
+
updatedInput.annotations = annotations;
|
|
53
|
+
return { behavior: "allow", updatedInput };
|
|
54
|
+
}
|
|
55
|
+
/** Machine-recognizable native grill-me question shape. */
|
|
56
|
+
export function isGrillMeProviderQuestion(event) {
|
|
57
|
+
const { question } = event;
|
|
58
|
+
if (event.questionCount !== 1 || question.multiSelect)
|
|
59
|
+
return false;
|
|
60
|
+
if (!/^grill-me\b/i.test(question.header ?? ""))
|
|
61
|
+
return false;
|
|
62
|
+
const labels = question.options.map((option) => option.label.trim());
|
|
63
|
+
if (labels.length < 3 || !labels[0]?.endsWith("(Recommended)"))
|
|
64
|
+
return false;
|
|
65
|
+
const stopIndex = labels.indexOf(GRILL_ME_STOP_CHOICE);
|
|
66
|
+
if (stopIndex < 0)
|
|
67
|
+
return false;
|
|
68
|
+
return labels.some((label, index) => index > 0 && index !== stopIndex && label.length > 0);
|
|
69
|
+
}
|
|
70
|
+
export function countProviderQuestions(input) {
|
|
71
|
+
const questions = input.questions;
|
|
72
|
+
return Array.isArray(questions) ? questions.length : 0;
|
|
73
|
+
}
|
|
74
|
+
function parseQuestions(input) {
|
|
75
|
+
const rawQuestions = input.questions;
|
|
76
|
+
if (!Array.isArray(rawQuestions))
|
|
77
|
+
return [];
|
|
78
|
+
return rawQuestions.flatMap((raw) => {
|
|
79
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
80
|
+
return [];
|
|
81
|
+
const record = raw;
|
|
82
|
+
const question = typeof record.question === "string" ? record.question.trim() : "";
|
|
83
|
+
if (!question)
|
|
84
|
+
return [];
|
|
85
|
+
const header = typeof record.header === "string" && record.header.trim()
|
|
86
|
+
? record.header.trim()
|
|
87
|
+
: undefined;
|
|
88
|
+
const options = Array.isArray(record.options)
|
|
89
|
+
? record.options.flatMap((option) => {
|
|
90
|
+
if (!option || typeof option !== "object" || Array.isArray(option))
|
|
91
|
+
return [];
|
|
92
|
+
const optionRecord = option;
|
|
93
|
+
const label = typeof optionRecord.label === "string" ? optionRecord.label.trim() : "";
|
|
94
|
+
if (!label)
|
|
95
|
+
return [];
|
|
96
|
+
return [{
|
|
97
|
+
label,
|
|
98
|
+
description: typeof optionRecord.description === "string" ? optionRecord.description : undefined,
|
|
99
|
+
preview: typeof optionRecord.preview === "string" ? optionRecord.preview : undefined,
|
|
100
|
+
}];
|
|
101
|
+
})
|
|
102
|
+
: [];
|
|
103
|
+
return [{ question, header, options, multiSelect: record.multiSelect === true }];
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
async function askOneQuestion(question, prompts, signal) {
|
|
107
|
+
const message = question.header ? `${question.header}: ${question.question}` : question.question;
|
|
108
|
+
if (question.options.length === 0) {
|
|
109
|
+
const answer = await prompts.text({ message, signal });
|
|
110
|
+
if (prompts.isCancel(answer))
|
|
111
|
+
return { cancelled: true, answer: "" };
|
|
112
|
+
return { cancelled: false, answer: String(answer) };
|
|
113
|
+
}
|
|
114
|
+
if (question.multiSelect) {
|
|
115
|
+
const selected = await prompts.multiselect({
|
|
116
|
+
message,
|
|
117
|
+
options: [
|
|
118
|
+
...question.options.map((option, index) => ({
|
|
119
|
+
value: String(index),
|
|
120
|
+
label: option.label,
|
|
121
|
+
hint: option.description,
|
|
122
|
+
})),
|
|
123
|
+
{ value: CUSTOM_VALUE, label: "Custom response", hint: "Type a different answer" },
|
|
124
|
+
],
|
|
125
|
+
required: true,
|
|
126
|
+
signal,
|
|
127
|
+
});
|
|
128
|
+
if (prompts.isCancel(selected))
|
|
129
|
+
return { cancelled: true, answer: "" };
|
|
130
|
+
const selectedValues = selected;
|
|
131
|
+
const labels = selectedValues
|
|
132
|
+
.filter((value) => value !== CUSTOM_VALUE)
|
|
133
|
+
.map((value) => question.options[Number(value)]?.label)
|
|
134
|
+
.filter((value) => Boolean(value));
|
|
135
|
+
const previews = selectedValues
|
|
136
|
+
.filter((value) => value !== CUSTOM_VALUE)
|
|
137
|
+
.map((value) => question.options[Number(value)]?.preview)
|
|
138
|
+
.filter((value) => Boolean(value));
|
|
139
|
+
let notes;
|
|
140
|
+
if (selectedValues.includes(CUSTOM_VALUE)) {
|
|
141
|
+
const custom = await prompts.text({ message: "Custom response:", signal });
|
|
142
|
+
if (prompts.isCancel(custom))
|
|
143
|
+
return { cancelled: true, answer: "" };
|
|
144
|
+
notes = String(custom);
|
|
145
|
+
labels.push(notes);
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
cancelled: false,
|
|
149
|
+
answer: labels.join(", "),
|
|
150
|
+
annotation: annotationFor(previews, notes),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const selected = await prompts.select({
|
|
154
|
+
message,
|
|
155
|
+
options: [
|
|
156
|
+
...question.options.map((option, index) => ({
|
|
157
|
+
value: String(index),
|
|
158
|
+
label: option.label,
|
|
159
|
+
hint: option.description,
|
|
160
|
+
})),
|
|
161
|
+
{ value: CUSTOM_VALUE, label: "Custom response", hint: "Type a different answer" },
|
|
162
|
+
],
|
|
163
|
+
signal,
|
|
164
|
+
});
|
|
165
|
+
if (prompts.isCancel(selected))
|
|
166
|
+
return { cancelled: true, answer: "" };
|
|
167
|
+
if (selected === CUSTOM_VALUE) {
|
|
168
|
+
const custom = await prompts.text({ message: "Custom response:", signal });
|
|
169
|
+
if (prompts.isCancel(custom))
|
|
170
|
+
return { cancelled: true, answer: "" };
|
|
171
|
+
const answer = String(custom);
|
|
172
|
+
return { cancelled: false, answer, annotation: { notes: answer } };
|
|
173
|
+
}
|
|
174
|
+
const option = question.options[Number(selected)];
|
|
175
|
+
return {
|
|
176
|
+
cancelled: false,
|
|
177
|
+
answer: option?.label ?? String(selected),
|
|
178
|
+
annotation: annotationFor(option?.preview ? [option.preview] : [], undefined),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function annotationFor(previews, notes) {
|
|
182
|
+
const annotation = {};
|
|
183
|
+
if (previews.length > 0)
|
|
184
|
+
annotation.preview = previews.join("\n\n");
|
|
185
|
+
if (notes)
|
|
186
|
+
annotation.notes = notes;
|
|
187
|
+
return Object.keys(annotation).length > 0 ? annotation : undefined;
|
|
188
|
+
}
|
|
189
|
+
function recordFromUnknown(value) {
|
|
190
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
191
|
+
return {};
|
|
192
|
+
const result = {};
|
|
193
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
194
|
+
if (typeof entry === "string")
|
|
195
|
+
result[key] = entry;
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
function annotationRecordFromUnknown(value) {
|
|
200
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
201
|
+
return {};
|
|
202
|
+
const result = {};
|
|
203
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
204
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
205
|
+
continue;
|
|
206
|
+
const record = entry;
|
|
207
|
+
const annotation = {};
|
|
208
|
+
if (typeof record.preview === "string")
|
|
209
|
+
annotation.preview = record.preview;
|
|
210
|
+
if (typeof record.notes === "string")
|
|
211
|
+
annotation.notes = record.notes;
|
|
212
|
+
if (Object.keys(annotation).length > 0)
|
|
213
|
+
result[key] = annotation;
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
package/dist/qaReview.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { currentActivity, withActivityPhase } from "./activity.js";
|
|
1
2
|
import { buildQaFixInstruction, buildQaInstruction, parseStepStatus } from "./foreman.js";
|
|
2
|
-
import {
|
|
3
|
+
import { createDisposableQaSnapshotAsync } from "./qaSnapshot.js";
|
|
3
4
|
export async function runIsolatedQa(opts) {
|
|
4
5
|
const history = [];
|
|
5
6
|
for (let cycle = 1; cycle <= opts.maxCycles; cycle++) {
|
|
6
|
-
const review = await oneReview(opts, cycle);
|
|
7
|
+
const review = await withActivityPhase(`running QA cycle ${cycle}/${opts.maxCycles}`, () => oneReview(opts, cycle));
|
|
7
8
|
if (review.outcome === "retry-modification") {
|
|
8
9
|
cycle -= 1;
|
|
9
10
|
continue;
|
|
@@ -15,7 +16,7 @@ export async function runIsolatedQa(opts) {
|
|
|
15
16
|
history.push({ cycle, outcome: "qa_fail", detail: review.detail });
|
|
16
17
|
if (cycle === opts.maxCycles)
|
|
17
18
|
break;
|
|
18
|
-
const fix = await opts.fix(review.detail ?? "QA reported unspecified issues");
|
|
19
|
+
const fix = await withActivityPhase("applying QA fixes", () => opts.fix(review.detail ?? "QA reported unspecified issues"));
|
|
19
20
|
if (!fix.ok)
|
|
20
21
|
return { outcome: "blocked", detail: fix.detail ?? "Builder QA fix failed" };
|
|
21
22
|
}
|
|
@@ -30,19 +31,20 @@ export async function runIsolatedQa(opts) {
|
|
|
30
31
|
const issues = decision.action === "remediate" ? decision.remediation : history.at(-1)?.detail;
|
|
31
32
|
if (!issues)
|
|
32
33
|
return { outcome: "nonconverged", detail: "Planner remediation did not produce approved fix instructions" };
|
|
33
|
-
const fix = await opts.fix(issues);
|
|
34
|
+
const fix = await withActivityPhase("applying QA remediation", () => opts.fix(issues));
|
|
34
35
|
if (!fix.ok)
|
|
35
36
|
return { outcome: "blocked", detail: fix.detail ?? "Builder QA fix failed" };
|
|
36
37
|
return runIsolatedQa(opts);
|
|
37
38
|
}
|
|
38
39
|
async function oneReview(opts, cycle) {
|
|
39
|
-
const
|
|
40
|
+
const progress = (state, detail) => currentActivity()?.update(state, detail);
|
|
41
|
+
const snapshot = await withActivityPhase("preparing disposable QA snapshot", () => createDisposableQaSnapshotAsync(opts.builderWorktree, progress));
|
|
40
42
|
let qa;
|
|
41
43
|
try {
|
|
42
44
|
const resume = opts.qaStrategy === "compact" && opts.state.modificationViolations === 0 ? opts.state.sessionId : undefined;
|
|
43
45
|
qa = await opts.createQa(snapshot.path, resume);
|
|
44
46
|
if (opts.qaStrategy === "compact" && opts.state.reviews > 0 && resume) {
|
|
45
|
-
const compacted = await compactWithRetry(qa);
|
|
47
|
+
const compacted = await withActivityPhase("compacting QA session", () => compactWithRetry(qa));
|
|
46
48
|
if (!compacted.ok) {
|
|
47
49
|
await qa.close();
|
|
48
50
|
qa = await opts.createQa(snapshot.path);
|
|
@@ -59,7 +61,7 @@ async function oneReview(opts, cycle) {
|
|
|
59
61
|
const status = parseStepStatus(turn.text);
|
|
60
62
|
opts.state.reviews += 1;
|
|
61
63
|
opts.state.sessionId = qa.sessionId();
|
|
62
|
-
const changes = snapshot.qaChanges();
|
|
64
|
+
const changes = await withActivityPhase("checking QA file changes", () => snapshot.qaChanges());
|
|
63
65
|
if (changes.length) {
|
|
64
66
|
opts.state.modificationViolations += 1;
|
|
65
67
|
opts.evidence?.({ cycle, outcome: "qa_file_modification", detail: "QA modified the disposable review copy", qaDiff: changes });
|
|
@@ -82,8 +84,9 @@ async function oneReview(opts, cycle) {
|
|
|
82
84
|
return { outcome: "needs-human", detail: status.error ?? `QA returned ${status.kind}` };
|
|
83
85
|
}
|
|
84
86
|
finally {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
if (qa)
|
|
88
|
+
await withActivityPhase("closing QA session", () => qa.close().catch(() => { }));
|
|
89
|
+
await withActivityPhase("cleaning up disposable QA snapshot", () => snapshot.remove());
|
|
87
90
|
}
|
|
88
91
|
}
|
|
89
92
|
export async function compactWithRetry(adapter) {
|
package/dist/qaSnapshot.d.ts
CHANGED
|
@@ -14,6 +14,20 @@ export interface DisposableQaSnapshot {
|
|
|
14
14
|
qaChanges(): string[];
|
|
15
15
|
remove(): void;
|
|
16
16
|
}
|
|
17
|
+
export interface AsyncDisposableQaSnapshot {
|
|
18
|
+
path: string;
|
|
19
|
+
manifest: QaChangeManifest;
|
|
20
|
+
verify(): Promise<void>;
|
|
21
|
+
qaChanges(): Promise<string[]>;
|
|
22
|
+
remove(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
export type QaSnapshotProgress = (state: string, detail?: string) => void;
|
|
17
25
|
/** Reproduce the Builder's exact change set in a detached disposable worktree. */
|
|
18
26
|
export declare function createDisposableQaSnapshot(builderWorktree: string): DisposableQaSnapshot;
|
|
27
|
+
/**
|
|
28
|
+
* Async form used by the live CLI. Git and filesystem work yields to the event
|
|
29
|
+
* loop so the activity reporter can keep animating during large snapshots.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createDisposableQaSnapshotAsync(builderWorktree: string, progress?: QaSnapshotProgress): Promise<AsyncDisposableQaSnapshot>;
|
|
19
32
|
export declare function changeManifest(worktree: string): QaChangeManifest;
|
|
33
|
+
export declare function changeManifestAsync(worktree: string, progress?: QaSnapshotProgress, state?: string): Promise<QaChangeManifest>;
|
package/dist/qaSnapshot.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
|
|
3
|
+
import { cp, lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink } from "node:fs/promises";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { dirname, join, resolve } from "node:path";
|
|
5
|
-
import { execFileSync, spawnSync } from "node:child_process";
|
|
6
|
+
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
6
7
|
/** Reproduce the Builder's exact change set in a detached disposable worktree. */
|
|
7
8
|
export function createDisposableQaSnapshot(builderWorktree) {
|
|
8
9
|
const source = resolve(builderWorktree);
|
|
@@ -47,6 +48,69 @@ export function createDisposableQaSnapshot(builderWorktree) {
|
|
|
47
48
|
throw error;
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Async form used by the live CLI. Git and filesystem work yields to the event
|
|
53
|
+
* loop so the activity reporter can keep animating during large snapshots.
|
|
54
|
+
*/
|
|
55
|
+
export async function createDisposableQaSnapshotAsync(builderWorktree, progress = () => { }) {
|
|
56
|
+
const source = resolve(builderWorktree);
|
|
57
|
+
progress("preparing disposable QA snapshot", "locating Builder worktree");
|
|
58
|
+
const root = (await runGit(source, ["rev-parse", "--show-toplevel"])).toString().trim();
|
|
59
|
+
const tempRoot = await mkdtemp(join(tmpdir(), "rafi-qa-"));
|
|
60
|
+
const review = join(tempRoot, "review");
|
|
61
|
+
const head = (await runGit(source, ["rev-parse", "HEAD"])).toString().trim();
|
|
62
|
+
progress("preparing disposable QA snapshot", "creating detached review worktree");
|
|
63
|
+
try {
|
|
64
|
+
await runGit(root, ["worktree", "add", "--detach", review, head]);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
68
|
+
throw new Error(`cannot create disposable QA worktree: ${errorMessage(error)}`);
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
progress("preparing disposable QA snapshot", "copying tracked Builder changes");
|
|
72
|
+
const diff = await runGit(source, ["diff", "--binary", "HEAD"]);
|
|
73
|
+
if (diff.length) {
|
|
74
|
+
try {
|
|
75
|
+
await runGit(review, ["apply", "--index", "--binary", "--whitespace=nowarn", "-"], diff);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
throw new Error(`cannot apply Builder diff to QA worktree: ${errorMessage(error)}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const paths = await untrackedPathsAsync(source);
|
|
82
|
+
for (let index = 0; index < paths.length; index++) {
|
|
83
|
+
progress("preparing disposable QA snapshot", `copying untracked files ${index + 1}/${paths.length}`);
|
|
84
|
+
await copyUntrackedAsync(source, review, paths[index]);
|
|
85
|
+
}
|
|
86
|
+
progress("preparing disposable QA snapshot", "recording Builder change manifest");
|
|
87
|
+
const manifest = await changeManifestAsync(source, progress, "preparing disposable QA snapshot");
|
|
88
|
+
const snapshot = {
|
|
89
|
+
path: review,
|
|
90
|
+
manifest,
|
|
91
|
+
verify: async () => {
|
|
92
|
+
const actual = await changeManifestAsync(review, progress, "preparing disposable QA snapshot");
|
|
93
|
+
if (JSON.stringify(actual) !== JSON.stringify(manifest)) {
|
|
94
|
+
throw new Error(`disposable QA snapshot does not match Builder change manifest: expected ${JSON.stringify(manifest)}, got ${JSON.stringify(actual)}`);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
qaChanges: async () => manifestDifference(manifest, await changeManifestAsync(review, progress)),
|
|
98
|
+
remove: async () => {
|
|
99
|
+
progress("cleaning up disposable QA snapshot", "removing detached review worktree");
|
|
100
|
+
await runGit(root, ["worktree", "remove", "--force", review]).catch(() => { });
|
|
101
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
progress("preparing disposable QA snapshot", "verifying review copy");
|
|
105
|
+
await snapshot.verify();
|
|
106
|
+
return snapshot;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
await runGit(root, ["worktree", "remove", "--force", review]).catch(() => { });
|
|
110
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
50
114
|
export function changeManifest(worktree) {
|
|
51
115
|
const diff = execFileSync("git", ["-C", worktree, "diff", "--binary", "HEAD"], { encoding: "buffer", maxBuffer: 128 * 1024 * 1024 });
|
|
52
116
|
const untracked = untrackedPaths(worktree).map((path) => {
|
|
@@ -58,10 +122,29 @@ export function changeManifest(worktree) {
|
|
|
58
122
|
});
|
|
59
123
|
return { diffDigest: createHash("sha256").update(diff).digest("hex"), untracked };
|
|
60
124
|
}
|
|
125
|
+
export async function changeManifestAsync(worktree, progress = () => { }, state = "checking QA file changes") {
|
|
126
|
+
const diff = await runGit(worktree, ["diff", "--binary", "HEAD"]);
|
|
127
|
+
const paths = await untrackedPathsAsync(worktree);
|
|
128
|
+
const untracked = [];
|
|
129
|
+
for (let index = 0; index < paths.length; index++) {
|
|
130
|
+
const path = paths[index];
|
|
131
|
+
progress(state, `hashing untracked files ${index + 1}/${paths.length}`);
|
|
132
|
+
const absolute = join(worktree, path);
|
|
133
|
+
const stat = await lstat(absolute);
|
|
134
|
+
const kind = stat.isSymbolicLink() ? "symlink" : "file";
|
|
135
|
+
const bytes = kind === "symlink" ? Buffer.from(await readlink(absolute)) : await readFile(absolute);
|
|
136
|
+
untracked.push({ path, kind, mode: stat.mode & 0o7777, digest: createHash("sha256").update(bytes).digest("hex") });
|
|
137
|
+
}
|
|
138
|
+
return { diffDigest: createHash("sha256").update(diff).digest("hex"), untracked };
|
|
139
|
+
}
|
|
61
140
|
function untrackedPaths(worktree) {
|
|
62
141
|
const raw = execFileSync("git", ["-C", worktree, "ls-files", "--others", "--exclude-standard", "-z"], { encoding: "buffer" });
|
|
63
142
|
return raw.toString().split("\0").filter(Boolean).sort();
|
|
64
143
|
}
|
|
144
|
+
async function untrackedPathsAsync(worktree) {
|
|
145
|
+
const raw = await runGit(worktree, ["ls-files", "--others", "--exclude-standard", "-z"]);
|
|
146
|
+
return raw.toString().split("\0").filter(Boolean).sort();
|
|
147
|
+
}
|
|
65
148
|
function copyUntracked(source, target, path) {
|
|
66
149
|
const from = join(source, path);
|
|
67
150
|
const to = join(target, path);
|
|
@@ -75,6 +158,39 @@ function copyUntracked(source, target, path) {
|
|
|
75
158
|
else
|
|
76
159
|
cpSync(from, to, { preserveTimestamps: true });
|
|
77
160
|
}
|
|
161
|
+
async function copyUntrackedAsync(source, target, path) {
|
|
162
|
+
const from = join(source, path);
|
|
163
|
+
const to = join(target, path);
|
|
164
|
+
await mkdir(dirname(to), { recursive: true });
|
|
165
|
+
const stat = await lstat(from);
|
|
166
|
+
if (stat.isSymbolicLink()) {
|
|
167
|
+
await rm(to, { force: true });
|
|
168
|
+
await symlink(await readlink(from), to);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
await cp(from, to, { preserveTimestamps: true });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function runGit(cwd, args, input) {
|
|
175
|
+
return new Promise((resolvePromise, reject) => {
|
|
176
|
+
const child = spawn("git", ["-C", cwd, ...args], { stdio: ["pipe", "pipe", "pipe"] });
|
|
177
|
+
const stdout = [];
|
|
178
|
+
const stderr = [];
|
|
179
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
180
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
181
|
+
child.on("error", reject);
|
|
182
|
+
child.on("close", (code) => {
|
|
183
|
+
if (code === 0)
|
|
184
|
+
resolvePromise(Buffer.concat(stdout));
|
|
185
|
+
else
|
|
186
|
+
reject(new Error(Buffer.concat(stderr).toString().trim() || `git exited with status ${code ?? "unknown"}`));
|
|
187
|
+
});
|
|
188
|
+
child.stdin.end(input);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
function errorMessage(error) {
|
|
192
|
+
return error instanceof Error ? error.message : String(error);
|
|
193
|
+
}
|
|
78
194
|
function manifestDifference(before, after) {
|
|
79
195
|
const changes = [];
|
|
80
196
|
if (before.diffDigest !== after.diffDigest)
|
package/dist/runtimeReadiness.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { accessSync, constants, statSync } from "node:fs";
|
|
3
3
|
import { delimiter, isAbsolute, resolve } from "node:path";
|
|
4
|
+
import { currentActivity, withActivityPhase } from "./activity.js";
|
|
4
5
|
export const RUNTIME_PROBE_TIMEOUT_MS = 30_000;
|
|
5
6
|
export const RUNTIME_DIAGNOSTIC_LIMIT = 8 * 1024;
|
|
6
7
|
const RELEVANT_ENV = /^(ANTHROPIC|CLAUDE|CODEX|OPENAI|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|SSL_CERT_FILE|NODE_EXTRA_CA_CERTS)(_|$)/i;
|
|
@@ -52,6 +53,9 @@ function isRunnableFile(path, platform) {
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
export async function probeRuntime(cwd, runtime, opts = {}) {
|
|
56
|
+
return withActivityPhase(`checking ${runtime} runtime`, () => probeRuntimeInternal(cwd, runtime, opts));
|
|
57
|
+
}
|
|
58
|
+
async function probeRuntimeInternal(cwd, runtime, opts) {
|
|
55
59
|
const env = opts.env ?? process.env;
|
|
56
60
|
const command = runtime === "claude" ? "claude" : "codex";
|
|
57
61
|
const executable = resolveExecutablePath(command, env) ?? command;
|
|
@@ -68,6 +72,7 @@ export async function probeRuntime(cwd, runtime, opts = {}) {
|
|
|
68
72
|
const spawnOpts = { cwd, env, stdio: ["ignore", "pipe", "pipe"] };
|
|
69
73
|
const child = spawn(executable, args, spawnOpts);
|
|
70
74
|
const append = (chunk) => {
|
|
75
|
+
currentActivity()?.pulse(`${runtime} runtime responded`);
|
|
71
76
|
if (output.length >= limit)
|
|
72
77
|
return;
|
|
73
78
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
6
|
import { parse, stringify } from "yaml";
|
|
6
7
|
import { fetchAndSnapshotUrl, normalizePublicHttpUrl, snapshotExternalLocalFile } from "../tickets/sourceFetch.js";
|
|
7
8
|
import { fetchJiraIssues, fetchLinearIssues } from "../tickets/importer.js";
|
|
9
|
+
import { withActivityPhase } from "../activity.js";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
8
11
|
export const SOURCE_REQUEST_START = "RAFI_SOURCE_REQUEST_START";
|
|
9
12
|
export const SOURCE_REQUEST_END = "RAFI_SOURCE_REQUEST_END";
|
|
10
13
|
const CONFIG_FILE = "rafi-config.yaml";
|
|
@@ -148,9 +151,12 @@ export function sourceRequestFromAnswer(answer, projectDir) {
|
|
|
148
151
|
if (/^https?:\/\/\S+$/i.test(value))
|
|
149
152
|
return requestFromUrl(value);
|
|
150
153
|
const candidate = resolve(projectDir, value);
|
|
151
|
-
if (value && existsSync(candidate))
|
|
152
|
-
|
|
153
|
-
|
|
154
|
+
if (value && existsSync(candidate)) {
|
|
155
|
+
const rel = relative(resolve(projectDir), candidate);
|
|
156
|
+
const path = !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel) ? rel || "." : candidate;
|
|
157
|
+
return { type: "local", label: basename(candidate), locator: { path } };
|
|
158
|
+
}
|
|
159
|
+
if (hasGlobMagic(value) || /^(?:\.{0,2}\/|\/)/.test(value) || /^[^\s]+[\\/][^\\/]+\.[A-Za-z0-9]{1,12}$/.test(value))
|
|
154
160
|
return { type: "local", label: basename(value), locator: { path: value } };
|
|
155
161
|
if (/^(?:open\s+)?issues\s+for\s+(?:this|the)\s+repo(?:sitory)?$/i.test(value))
|
|
156
162
|
return { type: inferOriginProvider(projectDir), label: "Open repository issues", locator: { repository: inferOriginRepository(projectDir), mode: "issues", filters: { state: "open" } } };
|
|
@@ -272,7 +278,7 @@ async function captureUrl(projectDir, entry, storage) {
|
|
|
272
278
|
rmSync(resolve(projectDir, fetched.metadataPath), { force: true });
|
|
273
279
|
return version;
|
|
274
280
|
}
|
|
275
|
-
function captureCliProvider(projectDir, entry, storage) {
|
|
281
|
+
async function captureCliProvider(projectDir, entry, storage) {
|
|
276
282
|
const repository = entry.locator.repository || inferOriginRepository(projectDir);
|
|
277
283
|
const mode = entry.locator.mode ?? "issues";
|
|
278
284
|
let command;
|
|
@@ -309,7 +315,8 @@ function captureCliProvider(projectDir, entry, storage) {
|
|
|
309
315
|
}
|
|
310
316
|
let text;
|
|
311
317
|
try {
|
|
312
|
-
|
|
318
|
+
const result = await withActivityPhase(`fetching ${entry.type} source`, () => execFileAsync(command, args, { cwd: projectDir, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }));
|
|
319
|
+
text = result.stdout;
|
|
313
320
|
}
|
|
314
321
|
catch (error) {
|
|
315
322
|
throw new Error(`${entry.type} source ${entry.label} could not be retrieved with authenticated ${command}: ${error instanceof Error ? error.message : String(error)}`);
|