ai-foreman 1.7.4 → 1.7.6
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 +6 -1
- package/dist/activity.d.ts +63 -0
- package/dist/activity.js +244 -0
- package/dist/adapters/claude.d.ts +7 -0
- package/dist/adapters/claude.js +41 -1
- 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 +15 -0
- package/dist/agentRun.d.ts +5 -0
- package/dist/agentRun.js +5 -1
- package/dist/branch/github.js +5 -3
- package/dist/branch/gitlab.js +5 -3
- package/dist/branch/planner.d.ts +5 -0
- package/dist/branch/planner.js +42 -3
- 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/buildRuns.d.ts +41 -17
- package/dist/buildRuns.js +243 -22
- package/dist/cli/events.js +24 -5
- package/dist/cli/runtimeAuthPrompt.js +5 -1
- package/dist/cli/start.js +138 -45
- package/dist/cli/tickets.d.ts +10 -1
- package/dist/cli/tickets.js +418 -79
- package/dist/foreman.d.ts +8 -5
- package/dist/foreman.js +38 -14
- package/dist/index.js +3 -1
- package/dist/providerQuestions.d.ts +23 -0
- package/dist/providerQuestions.js +24 -0
- package/dist/qaReview.d.ts +1 -0
- package/dist/qaReview.js +23 -15
- 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/details.d.ts +17 -0
- package/dist/tickets/details.js +94 -0
- package/dist/tickets/eligibility.d.ts +28 -0
- package/dist/tickets/eligibility.js +71 -0
- package/dist/tickets/importer.js +13 -8
- package/dist/tickets/reset.d.ts +27 -0
- package/dist/tickets/reset.js +213 -0
- package/dist/tickets/reviewStandards.d.ts +7 -0
- package/dist/tickets/reviewStandards.js +53 -0
- package/dist/tickets/setupConfig.d.ts +22 -0
- package/dist/tickets/setupConfig.js +105 -1
- package/dist/tickets/sourceFetch.js +19 -0
- package/dist/tickets/stateDb.d.ts +8 -1
- package/dist/tickets/stateDb.js +54 -23
- package/dist/workflowDb.d.ts +3 -0
- package/dist/workflowDb.js +26 -0
- package/package.json +15 -3
package/dist/foreman.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { BuilderAdapter, PermissionHandler, TurnResult } from "./adapters/types.js";
|
|
1
|
+
import type { BuilderAdapter, PermissionHandler, PermissionRequest, TurnResult } from "./adapters/types.js";
|
|
2
2
|
import type { PermissionPolicy } from "./permissions/policy.js";
|
|
3
|
+
import { type AnsweredProviderQuestion } from "./providerQuestions.js";
|
|
3
4
|
import type { Log } from "./log.js";
|
|
4
5
|
import { type QaNonconvergenceContext, type QaNonconvergenceDecision } from "./qaReview.js";
|
|
5
6
|
import type { SessionStrategy } from "rafi-spec";
|
|
@@ -26,7 +27,7 @@ export declare function looksLikeQuestion(text: string): boolean;
|
|
|
26
27
|
export { MARKER_SPEC } from "./markers.js";
|
|
27
28
|
export { QA_MARKER_SPEC } from "./markers.js";
|
|
28
29
|
/** Instruction sent on the first turn of a batch. */
|
|
29
|
-
export declare function buildPrimer(n: number, trackerPath?: string, ticketsEnabled?: boolean): string;
|
|
30
|
+
export declare function buildPrimer(n: number, trackerPath?: string, ticketsEnabled?: boolean, preferredTicketId?: string): string;
|
|
30
31
|
/** Instruction sent on turns 2–N. */
|
|
31
32
|
export declare function buildNextStepInstruction(i: number, n: number): string;
|
|
32
33
|
/** QA review turn: ask the builder to triple-check the ticket or step it just completed. */
|
|
@@ -34,10 +35,12 @@ export declare function buildQaInstruction(): string;
|
|
|
34
35
|
/** Follow-up turn after qa_fail: have the builder implement the listed fixes. */
|
|
35
36
|
export declare function buildQaFixInstruction(issues: string): string;
|
|
36
37
|
/** Pre-flight planning turn: ask the builder to list its next N steps without implementing anything. */
|
|
37
|
-
export declare function buildPlanningTurn(n: number, ticketsContent?: string): string;
|
|
38
|
+
export declare function buildPlanningTurn(n: number, ticketsContent?: string, preferredTicketId?: string): string;
|
|
38
39
|
/** Builds the permission callback: classify, log, allow or escalate. */
|
|
39
40
|
export declare function createPermissionHandler(policy: PermissionPolicy, log: Log, opts?: {
|
|
40
41
|
interactive?: boolean;
|
|
42
|
+
onAnsweredQuestion?: (event: AnsweredProviderQuestion) => void;
|
|
43
|
+
onProviderQuestion?: (request: PermissionRequest) => void;
|
|
41
44
|
}): PermissionHandler;
|
|
42
45
|
export interface BatchResult {
|
|
43
46
|
completed: number;
|
|
@@ -71,7 +74,7 @@ export declare class Foreman {
|
|
|
71
74
|
private prepareBuilderBoundary;
|
|
72
75
|
private doTurnWith;
|
|
73
76
|
/** Send a planning turn and return the builder's response text. Does not count toward steps. */
|
|
74
|
-
runPreflight(n: number, ticketsContent?: string): Promise<string>;
|
|
77
|
+
runPreflight(n: number, ticketsContent?: string, preferredTicketId?: string): Promise<string>;
|
|
75
78
|
/** Send user feedback on the plan; builder responds with a revised list. Does not count toward steps. */
|
|
76
79
|
sendPreflightFeedback(feedback: string): Promise<void>;
|
|
77
80
|
/** Send one custom instruction through the same needs_input loop as a batch turn. */
|
|
@@ -95,5 +98,5 @@ export declare class Foreman {
|
|
|
95
98
|
builderAdapter(): BuilderAdapter;
|
|
96
99
|
close(): Promise<void>;
|
|
97
100
|
private ticketForQa;
|
|
98
|
-
runBatch(n: number, trackerPath?: string): Promise<BatchResult>;
|
|
101
|
+
runBatch(n: number, trackerPath?: string, onTicketStart?: (ticketId: string) => void | Promise<void>, preferredTicketId?: string): Promise<BatchResult>;
|
|
99
102
|
}
|
package/dist/foreman.js
CHANGED
|
@@ -107,7 +107,7 @@ export function looksLikeQuestion(text) {
|
|
|
107
107
|
export { MARKER_SPEC } from "./markers.js";
|
|
108
108
|
export { QA_MARKER_SPEC } from "./markers.js";
|
|
109
109
|
/** Instruction sent on the first turn of a batch. */
|
|
110
|
-
export function buildPrimer(n, trackerPath, ticketsEnabled = false) {
|
|
110
|
+
export function buildPrimer(n, trackerPath, ticketsEnabled = false, preferredTicketId) {
|
|
111
111
|
let trackerRule;
|
|
112
112
|
if (ticketsEnabled) {
|
|
113
113
|
const progressDoc = trackerPath ?? "docs/ticket-progress.md";
|
|
@@ -122,12 +122,15 @@ export function buildPrimer(n, trackerPath, ticketsEnabled = false) {
|
|
|
122
122
|
else {
|
|
123
123
|
trackerRule = "";
|
|
124
124
|
}
|
|
125
|
+
const preferredTicketRule = preferredTicketId
|
|
126
|
+
? `\n- Resume and finish ticket ${preferredTicketId} first. Do not substitute a different queued ticket.`
|
|
127
|
+
: "";
|
|
125
128
|
return `You are being run by an automated foreman. We will work through your next ${n} tickets or implementation steps, one per turn.
|
|
126
129
|
|
|
127
130
|
Rules:
|
|
128
131
|
- Do exactly ONE ticket or step this turn, then stop.
|
|
129
132
|
- ${MARKER_SPEC}
|
|
130
|
-
- If a tool action is denied by foreman policy, do not retry it; report it via the blocked marker.${trackerRule}
|
|
133
|
+
- If a tool action is denied by foreman policy, do not retry it; report it via the blocked marker.${trackerRule}${preferredTicketRule}
|
|
131
134
|
|
|
132
135
|
This is step 1 of ${n}. Implement the next ticket or step now.`;
|
|
133
136
|
}
|
|
@@ -160,17 +163,23 @@ ${issues}
|
|
|
160
163
|
Fix every one of them now. Then end with STEP_STATUS: done so foreman can re-run QA on the fixes. Triple-check that your fixes actually resolve the issues before emitting done.`;
|
|
161
164
|
}
|
|
162
165
|
/** Pre-flight planning turn: ask the builder to list its next N steps without implementing anything. */
|
|
163
|
-
export function buildPlanningTurn(n, ticketsContent) {
|
|
166
|
+
export function buildPlanningTurn(n, ticketsContent, preferredTicketId) {
|
|
164
167
|
const header = ticketsContent
|
|
165
168
|
? `Here is the project's ticket list:\n\n${ticketsContent}\n\n`
|
|
166
169
|
: "";
|
|
167
|
-
|
|
170
|
+
const preferred = preferredTicketId
|
|
171
|
+
? ` The first item must be ticket ${preferredTicketId}; this is an interrupted ticket being resumed.`
|
|
172
|
+
: "";
|
|
173
|
+
return `${header}Before we begin, list the next ${n} ticket(s) or step(s) you plan to implement, in order.${preferred} 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.`;
|
|
168
174
|
}
|
|
169
175
|
/** Builds the permission callback: classify, log, allow or escalate. */
|
|
170
176
|
export function createPermissionHandler(policy, log, opts = {}) {
|
|
171
177
|
return async (req) => {
|
|
178
|
+
if (req.toolName === "AskUserQuestion")
|
|
179
|
+
opts.onProviderQuestion?.(req);
|
|
172
180
|
const providerQuestion = await handleProviderQuestionTool(req, {
|
|
173
181
|
interactive: opts.interactive ?? true,
|
|
182
|
+
onAnsweredQuestion: opts.onAnsweredQuestion,
|
|
174
183
|
});
|
|
175
184
|
if (providerQuestion) {
|
|
176
185
|
log.write("permission", {
|
|
@@ -315,8 +324,8 @@ export class Foreman {
|
|
|
315
324
|
return { result, status };
|
|
316
325
|
}
|
|
317
326
|
/** Send a planning turn and return the builder's response text. Does not count toward steps. */
|
|
318
|
-
async runPreflight(n, ticketsContent) {
|
|
319
|
-
const instruction = buildPlanningTurn(n, ticketsContent);
|
|
327
|
+
async runPreflight(n, ticketsContent, preferredTicketId) {
|
|
328
|
+
const instruction = buildPlanningTurn(n, ticketsContent, preferredTicketId);
|
|
320
329
|
const result = await this.builder.sendTurn(instruction);
|
|
321
330
|
this.log.write("preflight", {
|
|
322
331
|
ticketsProvided: ticketsContent !== undefined,
|
|
@@ -343,10 +352,10 @@ export class Foreman {
|
|
|
343
352
|
* Loops on qa_fail → fix → re-QA until qa_pass or the cycle cap is reached.
|
|
344
353
|
* QA turns are free — they do not advance the step counter.
|
|
345
354
|
*/
|
|
346
|
-
async runQa(stepIndex) {
|
|
355
|
+
async runQa(stepIndex, ticketId) {
|
|
347
356
|
if (this.projectDir && this.qaFactory) {
|
|
348
357
|
const review = await runIsolatedQa({
|
|
349
|
-
ticket: this.ticketForQa(stepIndex),
|
|
358
|
+
ticket: this.ticketForQa(stepIndex, ticketId),
|
|
350
359
|
builderWorktree: this.projectDir,
|
|
351
360
|
builderSummary: `Builder completed step ${stepIndex}`,
|
|
352
361
|
qaStrategy: this.qaSessionStrategy,
|
|
@@ -436,10 +445,12 @@ export class Foreman {
|
|
|
436
445
|
builderSessionId() { return this.builder.sessionId(); }
|
|
437
446
|
builderAdapter() { return this.builder; }
|
|
438
447
|
async close() { await this.builder.close(); }
|
|
439
|
-
ticketForQa(stepIndex) {
|
|
448
|
+
ticketForQa(stepIndex, ticketId) {
|
|
440
449
|
if (this.projectDir && this.ticketsEnabled) {
|
|
441
450
|
const config = loadTicketsConfig(this.projectDir);
|
|
442
|
-
const active =
|
|
451
|
+
const active = ticketId
|
|
452
|
+
? { ticket: ticketId }
|
|
453
|
+
: cmdImplementationQueue(this.projectDir).find((row) => row.status === "next" || row.status === "in_progress");
|
|
443
454
|
const ticket = loadTickets(join(this.projectDir, config.paths.tickets)).find((candidate) => candidate.id === active?.ticket);
|
|
444
455
|
if (ticket)
|
|
445
456
|
return ticket;
|
|
@@ -450,7 +461,7 @@ export class Foreman {
|
|
|
450
461
|
acceptance: ["The requested implementation step is complete"], required_tests: ["Run the relevant project validation"], likely_files: [],
|
|
451
462
|
};
|
|
452
463
|
}
|
|
453
|
-
async runBatch(n, trackerPath) {
|
|
464
|
+
async runBatch(n, trackerPath, onTicketStart, preferredTicketId) {
|
|
454
465
|
this.log.write("batch-start", { requested: n, agent: this.builder.agent });
|
|
455
466
|
let completed = 0;
|
|
456
467
|
let outcome = "all-done";
|
|
@@ -461,9 +472,22 @@ export class Foreman {
|
|
|
461
472
|
if (this.ticketsEnabled && this.projectDir) {
|
|
462
473
|
try {
|
|
463
474
|
const queue = cmdImplementationQueue(this.projectDir);
|
|
464
|
-
const next =
|
|
475
|
+
const next = i === 1 && preferredTicketId
|
|
476
|
+
? queue.find((row) => row.ticket === preferredTicketId)
|
|
477
|
+
: queue.find((row) => row.status === "next");
|
|
478
|
+
if (i === 1 && preferredTicketId && !next) {
|
|
479
|
+
outcome = "needs-human";
|
|
480
|
+
detail = `recovery ticket ${preferredTicketId} is no longer available in the implementation queue`;
|
|
481
|
+
break;
|
|
482
|
+
}
|
|
483
|
+
if (i === 1 && preferredTicketId && next && next.status !== "next" && next.status !== "in_progress") {
|
|
484
|
+
outcome = "needs-human";
|
|
485
|
+
detail = `recovery ticket ${preferredTicketId} cannot resume while its status is ${next.status}`;
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
465
488
|
if (next) {
|
|
466
489
|
pendingTicketId = next.ticket;
|
|
490
|
+
await onTicketStart?.(next.ticket);
|
|
467
491
|
cmdUpdate(this.projectDir, next.ticket, {
|
|
468
492
|
status: "in_progress",
|
|
469
493
|
actor: "foreman",
|
|
@@ -478,7 +502,7 @@ export class Foreman {
|
|
|
478
502
|
}
|
|
479
503
|
}
|
|
480
504
|
const instruction = i === 1
|
|
481
|
-
? buildPrimer(n, trackerPath, this.ticketsEnabled)
|
|
505
|
+
? buildPrimer(n, trackerPath, this.ticketsEnabled, preferredTicketId)
|
|
482
506
|
: buildNextStepInstruction(i, n);
|
|
483
507
|
if (i > 1)
|
|
484
508
|
await this.prepareBuilderBoundary();
|
|
@@ -503,7 +527,7 @@ export class Foreman {
|
|
|
503
527
|
let qaSummary;
|
|
504
528
|
let qaWaived = false;
|
|
505
529
|
if (this.qaEnabled) {
|
|
506
|
-
const qa = await this.runQa(i);
|
|
530
|
+
const qa = await this.runQa(i, status.ticket ?? pendingTicketId);
|
|
507
531
|
if (qa.outcome === "blocked") {
|
|
508
532
|
outcome = "blocked";
|
|
509
533
|
detail = qa.detail;
|
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
|
});
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { isCancel, multiselect, select, text } from "@clack/prompts";
|
|
2
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
|
+
}
|
|
3
14
|
export interface ProviderQuestionPromptDeps {
|
|
4
15
|
select: typeof select;
|
|
5
16
|
multiselect: typeof multiselect;
|
|
@@ -9,6 +20,18 @@ export interface ProviderQuestionPromptDeps {
|
|
|
9
20
|
export interface ProviderQuestionOptions {
|
|
10
21
|
interactive: boolean;
|
|
11
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;
|
|
12
32
|
}
|
|
33
|
+
export declare const GRILL_ME_STOP_CHOICE = "Stop questions and make the plan now";
|
|
13
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;
|
|
14
37
|
export declare function countProviderQuestions(input: Record<string, unknown>): number;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isCancel, multiselect, select, text } from "@clack/prompts";
|
|
2
2
|
const ASK_USER_QUESTION_TOOL = "AskUserQuestion";
|
|
3
3
|
const CUSTOM_VALUE = "__rafi_custom_response__";
|
|
4
|
+
export const GRILL_ME_STOP_CHOICE = "Stop questions and make the plan now";
|
|
4
5
|
export async function handleProviderQuestionTool(req, opts) {
|
|
5
6
|
if (req.toolName !== ASK_USER_QUESTION_TOOL)
|
|
6
7
|
return undefined;
|
|
@@ -34,6 +35,14 @@ export async function handleProviderQuestionTool(req, opts) {
|
|
|
34
35
|
answers[question.question] = answered.answer;
|
|
35
36
|
if (answered.annotation)
|
|
36
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
|
+
}
|
|
37
46
|
}
|
|
38
47
|
const updatedInput = {
|
|
39
48
|
...req.input,
|
|
@@ -43,6 +52,21 @@ export async function handleProviderQuestionTool(req, opts) {
|
|
|
43
52
|
updatedInput.annotations = annotations;
|
|
44
53
|
return { behavior: "allow", updatedInput };
|
|
45
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
|
+
}
|
|
46
70
|
export function countProviderQuestions(input) {
|
|
47
71
|
const questions = input.questions;
|
|
48
72
|
return Array.isArray(questions) ? questions.length : 0;
|
package/dist/qaReview.d.ts
CHANGED
|
@@ -45,6 +45,7 @@ export interface IsolatedQaResult {
|
|
|
45
45
|
summary?: string;
|
|
46
46
|
}
|
|
47
47
|
export declare function runIsolatedQa(opts: IsolatedQaOptions): Promise<IsolatedQaResult>;
|
|
48
|
+
export declare function buildQaReviewHandoff(ticket: TicketDef, builderSummary: string, diffDigest: string, validationChecklist: string[]): string;
|
|
48
49
|
export declare function compactWithRetry(adapter: BuilderAdapter): Promise<{
|
|
49
50
|
ok: boolean;
|
|
50
51
|
error?: string;
|
package/dist/qaReview.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { currentActivity, withActivityPhase } from "./activity.js";
|
|
1
2
|
import { buildQaFixInstruction, buildQaInstruction, parseStepStatus } from "./foreman.js";
|
|
2
|
-
import {
|
|
3
|
+
import { createDisposableQaSnapshotAsync } from "./qaSnapshot.js";
|
|
4
|
+
import { loadTicketSetupConfigWithDefaults } from "./tickets/setupConfig.js";
|
|
3
5
|
export async function runIsolatedQa(opts) {
|
|
4
6
|
const history = [];
|
|
5
7
|
for (let cycle = 1; cycle <= opts.maxCycles; cycle++) {
|
|
6
|
-
const review = await oneReview(opts, cycle);
|
|
8
|
+
const review = await withActivityPhase(`running QA cycle ${cycle}/${opts.maxCycles}`, () => oneReview(opts, cycle));
|
|
7
9
|
if (review.outcome === "retry-modification") {
|
|
8
10
|
cycle -= 1;
|
|
9
11
|
continue;
|
|
@@ -15,7 +17,7 @@ export async function runIsolatedQa(opts) {
|
|
|
15
17
|
history.push({ cycle, outcome: "qa_fail", detail: review.detail });
|
|
16
18
|
if (cycle === opts.maxCycles)
|
|
17
19
|
break;
|
|
18
|
-
const fix = await opts.fix(review.detail ?? "QA reported unspecified issues");
|
|
20
|
+
const fix = await withActivityPhase("applying QA fixes", () => opts.fix(review.detail ?? "QA reported unspecified issues"));
|
|
19
21
|
if (!fix.ok)
|
|
20
22
|
return { outcome: "blocked", detail: fix.detail ?? "Builder QA fix failed" };
|
|
21
23
|
}
|
|
@@ -30,36 +32,32 @@ export async function runIsolatedQa(opts) {
|
|
|
30
32
|
const issues = decision.action === "remediate" ? decision.remediation : history.at(-1)?.detail;
|
|
31
33
|
if (!issues)
|
|
32
34
|
return { outcome: "nonconverged", detail: "Planner remediation did not produce approved fix instructions" };
|
|
33
|
-
const fix = await opts.fix(issues);
|
|
35
|
+
const fix = await withActivityPhase("applying QA remediation", () => opts.fix(issues));
|
|
34
36
|
if (!fix.ok)
|
|
35
37
|
return { outcome: "blocked", detail: fix.detail ?? "Builder QA fix failed" };
|
|
36
38
|
return runIsolatedQa(opts);
|
|
37
39
|
}
|
|
38
40
|
async function oneReview(opts, cycle) {
|
|
39
|
-
const
|
|
41
|
+
const progress = (state, detail) => currentActivity()?.update(state, detail);
|
|
42
|
+
const snapshot = await withActivityPhase("preparing disposable QA snapshot", () => createDisposableQaSnapshotAsync(opts.builderWorktree, progress));
|
|
40
43
|
let qa;
|
|
41
44
|
try {
|
|
42
45
|
const resume = opts.qaStrategy === "compact" && opts.state.modificationViolations === 0 ? opts.state.sessionId : undefined;
|
|
43
46
|
qa = await opts.createQa(snapshot.path, resume);
|
|
44
47
|
if (opts.qaStrategy === "compact" && opts.state.reviews > 0 && resume) {
|
|
45
|
-
const compacted = await compactWithRetry(qa);
|
|
48
|
+
const compacted = await withActivityPhase("compacting QA session", () => compactWithRetry(qa));
|
|
46
49
|
if (!compacted.ok) {
|
|
47
50
|
await qa.close();
|
|
48
51
|
qa = await opts.createQa(snapshot.path);
|
|
49
52
|
opts.state.sessionId = undefined;
|
|
50
53
|
}
|
|
51
54
|
}
|
|
52
|
-
const handoff =
|
|
53
|
-
buildQaInstruction(), "", "QA handoff:", `Ticket: ${opts.ticket.id} — ${opts.ticket.title}`,
|
|
54
|
-
`Acceptance: ${opts.ticket.acceptance.join("; ")}`, `Builder summary: ${opts.builderSummary}`,
|
|
55
|
-
`Changed files/diff digest: ${snapshot.manifest.diffDigest}`, `Required tests: ${opts.ticket.required_tests.join("; ")}`,
|
|
56
|
-
"Do not change source, configuration, documentation, tickets, or control files. Only ignored cache, coverage, and build output is allowed.",
|
|
57
|
-
].join("\n");
|
|
55
|
+
const handoff = buildQaReviewHandoff(opts.ticket, opts.builderSummary, snapshot.manifest.diffDigest, loadTicketSetupConfigWithDefaults(opts.builderWorktree).build.validation_checklist);
|
|
58
56
|
const turn = await qa.sendTurn(handoff);
|
|
59
57
|
const status = parseStepStatus(turn.text);
|
|
60
58
|
opts.state.reviews += 1;
|
|
61
59
|
opts.state.sessionId = qa.sessionId();
|
|
62
|
-
const changes = snapshot.qaChanges();
|
|
60
|
+
const changes = await withActivityPhase("checking QA file changes", () => snapshot.qaChanges());
|
|
63
61
|
if (changes.length) {
|
|
64
62
|
opts.state.modificationViolations += 1;
|
|
65
63
|
opts.evidence?.({ cycle, outcome: "qa_file_modification", detail: "QA modified the disposable review copy", qaDiff: changes });
|
|
@@ -82,10 +80,20 @@ async function oneReview(opts, cycle) {
|
|
|
82
80
|
return { outcome: "needs-human", detail: status.error ?? `QA returned ${status.kind}` };
|
|
83
81
|
}
|
|
84
82
|
finally {
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
if (qa)
|
|
84
|
+
await withActivityPhase("closing QA session", () => qa.close().catch(() => { }));
|
|
85
|
+
await withActivityPhase("cleaning up disposable QA snapshot", () => snapshot.remove());
|
|
87
86
|
}
|
|
88
87
|
}
|
|
88
|
+
export function buildQaReviewHandoff(ticket, builderSummary, diffDigest, validationChecklist) {
|
|
89
|
+
return [
|
|
90
|
+
buildQaInstruction(), "", "QA handoff:", `Ticket: ${ticket.id} — ${ticket.title}`,
|
|
91
|
+
`Acceptance: ${ticket.acceptance.join("; ")}`, `Builder summary: ${builderSummary}`,
|
|
92
|
+
`Changed files/diff digest: ${diffDigest}`, `Required tests: ${ticket.required_tests.join("; ")}`,
|
|
93
|
+
`Project validation checklist: ${validationChecklist.join("; ")}`,
|
|
94
|
+
"Do not change source, configuration, documentation, tickets, or control files. Only ignored cache, coverage, and build output is allowed.",
|
|
95
|
+
].join("\n");
|
|
96
|
+
}
|
|
89
97
|
export async function compactWithRetry(adapter) {
|
|
90
98
|
if (!adapter.compact)
|
|
91
99
|
return { ok: false, error: "native compaction unavailable" };
|
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)}`);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type DeliveryStack, type DeliveryUnit } from "./delivery.js";
|
|
2
|
+
import { type TicketEvent, type TicketState, type ValidationSnapshot } from "./stateDb.js";
|
|
3
|
+
import type { TicketDef } from "./ticketSchema.js";
|
|
4
|
+
export interface TicketDeliveryDetails {
|
|
5
|
+
units: DeliveryUnit[];
|
|
6
|
+
stacks: DeliveryStack[];
|
|
7
|
+
}
|
|
8
|
+
export interface TicketDetails {
|
|
9
|
+
definition: TicketDef & Record<string, unknown>;
|
|
10
|
+
state: TicketState;
|
|
11
|
+
effective_blockers: string[];
|
|
12
|
+
validation_history: ValidationSnapshot[];
|
|
13
|
+
events: TicketEvent[];
|
|
14
|
+
delivery: TicketDeliveryDetails;
|
|
15
|
+
}
|
|
16
|
+
export declare function getTicketDetails(projectDir: string, ticketId: string, now?: Date): TicketDetails;
|
|
17
|
+
export declare function formatTicketDetails(details: TicketDetails): string[];
|