ai-foreman 1.7.5 → 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 +1 -1
- package/dist/branch/github.js +3 -2
- package/dist/branch/gitlab.js +3 -2
- package/dist/branch/planner.d.ts +5 -0
- package/dist/branch/planner.js +42 -3
- package/dist/buildRuns.d.ts +41 -17
- package/dist/buildRuns.js +243 -22
- package/dist/cli/start.js +104 -24
- package/dist/cli/tickets.d.ts +10 -1
- package/dist/cli/tickets.js +418 -79
- package/dist/foreman.d.ts +4 -4
- package/dist/foreman.js +35 -14
- package/dist/qaReview.d.ts +1 -0
- package/dist/qaReview.js +11 -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/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/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 +7 -3
package/dist/foreman.d.ts
CHANGED
|
@@ -27,7 +27,7 @@ export declare function looksLikeQuestion(text: string): boolean;
|
|
|
27
27
|
export { MARKER_SPEC } from "./markers.js";
|
|
28
28
|
export { QA_MARKER_SPEC } from "./markers.js";
|
|
29
29
|
/** Instruction sent on the first turn of a batch. */
|
|
30
|
-
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;
|
|
31
31
|
/** Instruction sent on turns 2–N. */
|
|
32
32
|
export declare function buildNextStepInstruction(i: number, n: number): string;
|
|
33
33
|
/** QA review turn: ask the builder to triple-check the ticket or step it just completed. */
|
|
@@ -35,7 +35,7 @@ export declare function buildQaInstruction(): string;
|
|
|
35
35
|
/** Follow-up turn after qa_fail: have the builder implement the listed fixes. */
|
|
36
36
|
export declare function buildQaFixInstruction(issues: string): string;
|
|
37
37
|
/** Pre-flight planning turn: ask the builder to list its next N steps without implementing anything. */
|
|
38
|
-
export declare function buildPlanningTurn(n: number, ticketsContent?: string): string;
|
|
38
|
+
export declare function buildPlanningTurn(n: number, ticketsContent?: string, preferredTicketId?: string): string;
|
|
39
39
|
/** Builds the permission callback: classify, log, allow or escalate. */
|
|
40
40
|
export declare function createPermissionHandler(policy: PermissionPolicy, log: Log, opts?: {
|
|
41
41
|
interactive?: boolean;
|
|
@@ -74,7 +74,7 @@ export declare class Foreman {
|
|
|
74
74
|
private prepareBuilderBoundary;
|
|
75
75
|
private doTurnWith;
|
|
76
76
|
/** Send a planning turn and return the builder's response text. Does not count toward steps. */
|
|
77
|
-
runPreflight(n: number, ticketsContent?: string): Promise<string>;
|
|
77
|
+
runPreflight(n: number, ticketsContent?: string, preferredTicketId?: string): Promise<string>;
|
|
78
78
|
/** Send user feedback on the plan; builder responds with a revised list. Does not count toward steps. */
|
|
79
79
|
sendPreflightFeedback(feedback: string): Promise<void>;
|
|
80
80
|
/** Send one custom instruction through the same needs_input loop as a batch turn. */
|
|
@@ -98,5 +98,5 @@ export declare class Foreman {
|
|
|
98
98
|
builderAdapter(): BuilderAdapter;
|
|
99
99
|
close(): Promise<void>;
|
|
100
100
|
private ticketForQa;
|
|
101
|
-
runBatch(n: number, trackerPath?: string): Promise<BatchResult>;
|
|
101
|
+
runBatch(n: number, trackerPath?: string, onTicketStart?: (ticketId: string) => void | Promise<void>, preferredTicketId?: string): Promise<BatchResult>;
|
|
102
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,11 +163,14 @@ ${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 = {}) {
|
|
@@ -318,8 +324,8 @@ export class Foreman {
|
|
|
318
324
|
return { result, status };
|
|
319
325
|
}
|
|
320
326
|
/** Send a planning turn and return the builder's response text. Does not count toward steps. */
|
|
321
|
-
async runPreflight(n, ticketsContent) {
|
|
322
|
-
const instruction = buildPlanningTurn(n, ticketsContent);
|
|
327
|
+
async runPreflight(n, ticketsContent, preferredTicketId) {
|
|
328
|
+
const instruction = buildPlanningTurn(n, ticketsContent, preferredTicketId);
|
|
323
329
|
const result = await this.builder.sendTurn(instruction);
|
|
324
330
|
this.log.write("preflight", {
|
|
325
331
|
ticketsProvided: ticketsContent !== undefined,
|
|
@@ -346,10 +352,10 @@ export class Foreman {
|
|
|
346
352
|
* Loops on qa_fail → fix → re-QA until qa_pass or the cycle cap is reached.
|
|
347
353
|
* QA turns are free — they do not advance the step counter.
|
|
348
354
|
*/
|
|
349
|
-
async runQa(stepIndex) {
|
|
355
|
+
async runQa(stepIndex, ticketId) {
|
|
350
356
|
if (this.projectDir && this.qaFactory) {
|
|
351
357
|
const review = await runIsolatedQa({
|
|
352
|
-
ticket: this.ticketForQa(stepIndex),
|
|
358
|
+
ticket: this.ticketForQa(stepIndex, ticketId),
|
|
353
359
|
builderWorktree: this.projectDir,
|
|
354
360
|
builderSummary: `Builder completed step ${stepIndex}`,
|
|
355
361
|
qaStrategy: this.qaSessionStrategy,
|
|
@@ -439,10 +445,12 @@ export class Foreman {
|
|
|
439
445
|
builderSessionId() { return this.builder.sessionId(); }
|
|
440
446
|
builderAdapter() { return this.builder; }
|
|
441
447
|
async close() { await this.builder.close(); }
|
|
442
|
-
ticketForQa(stepIndex) {
|
|
448
|
+
ticketForQa(stepIndex, ticketId) {
|
|
443
449
|
if (this.projectDir && this.ticketsEnabled) {
|
|
444
450
|
const config = loadTicketsConfig(this.projectDir);
|
|
445
|
-
const active =
|
|
451
|
+
const active = ticketId
|
|
452
|
+
? { ticket: ticketId }
|
|
453
|
+
: cmdImplementationQueue(this.projectDir).find((row) => row.status === "next" || row.status === "in_progress");
|
|
446
454
|
const ticket = loadTickets(join(this.projectDir, config.paths.tickets)).find((candidate) => candidate.id === active?.ticket);
|
|
447
455
|
if (ticket)
|
|
448
456
|
return ticket;
|
|
@@ -453,7 +461,7 @@ export class Foreman {
|
|
|
453
461
|
acceptance: ["The requested implementation step is complete"], required_tests: ["Run the relevant project validation"], likely_files: [],
|
|
454
462
|
};
|
|
455
463
|
}
|
|
456
|
-
async runBatch(n, trackerPath) {
|
|
464
|
+
async runBatch(n, trackerPath, onTicketStart, preferredTicketId) {
|
|
457
465
|
this.log.write("batch-start", { requested: n, agent: this.builder.agent });
|
|
458
466
|
let completed = 0;
|
|
459
467
|
let outcome = "all-done";
|
|
@@ -464,9 +472,22 @@ export class Foreman {
|
|
|
464
472
|
if (this.ticketsEnabled && this.projectDir) {
|
|
465
473
|
try {
|
|
466
474
|
const queue = cmdImplementationQueue(this.projectDir);
|
|
467
|
-
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
|
+
}
|
|
468
488
|
if (next) {
|
|
469
489
|
pendingTicketId = next.ticket;
|
|
490
|
+
await onTicketStart?.(next.ticket);
|
|
470
491
|
cmdUpdate(this.projectDir, next.ticket, {
|
|
471
492
|
status: "in_progress",
|
|
472
493
|
actor: "foreman",
|
|
@@ -481,7 +502,7 @@ export class Foreman {
|
|
|
481
502
|
}
|
|
482
503
|
}
|
|
483
504
|
const instruction = i === 1
|
|
484
|
-
? buildPrimer(n, trackerPath, this.ticketsEnabled)
|
|
505
|
+
? buildPrimer(n, trackerPath, this.ticketsEnabled, preferredTicketId)
|
|
485
506
|
: buildNextStepInstruction(i, n);
|
|
486
507
|
if (i > 1)
|
|
487
508
|
await this.prepareBuilderBoundary();
|
|
@@ -506,7 +527,7 @@ export class Foreman {
|
|
|
506
527
|
let qaSummary;
|
|
507
528
|
let qaWaived = false;
|
|
508
529
|
if (this.qaEnabled) {
|
|
509
|
-
const qa = await this.runQa(i);
|
|
530
|
+
const qa = await this.runQa(i, status.ticket ?? pendingTicketId);
|
|
510
531
|
if (qa.outcome === "blocked") {
|
|
511
532
|
outcome = "blocked";
|
|
512
533
|
detail = qa.detail;
|
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,6 +1,7 @@
|
|
|
1
1
|
import { currentActivity, withActivityPhase } from "./activity.js";
|
|
2
2
|
import { buildQaFixInstruction, buildQaInstruction, parseStepStatus } from "./foreman.js";
|
|
3
3
|
import { createDisposableQaSnapshotAsync } from "./qaSnapshot.js";
|
|
4
|
+
import { loadTicketSetupConfigWithDefaults } from "./tickets/setupConfig.js";
|
|
4
5
|
export async function runIsolatedQa(opts) {
|
|
5
6
|
const history = [];
|
|
6
7
|
for (let cycle = 1; cycle <= opts.maxCycles; cycle++) {
|
|
@@ -51,12 +52,7 @@ async function oneReview(opts, cycle) {
|
|
|
51
52
|
opts.state.sessionId = undefined;
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
|
-
const handoff =
|
|
55
|
-
buildQaInstruction(), "", "QA handoff:", `Ticket: ${opts.ticket.id} — ${opts.ticket.title}`,
|
|
56
|
-
`Acceptance: ${opts.ticket.acceptance.join("; ")}`, `Builder summary: ${opts.builderSummary}`,
|
|
57
|
-
`Changed files/diff digest: ${snapshot.manifest.diffDigest}`, `Required tests: ${opts.ticket.required_tests.join("; ")}`,
|
|
58
|
-
"Do not change source, configuration, documentation, tickets, or control files. Only ignored cache, coverage, and build output is allowed.",
|
|
59
|
-
].join("\n");
|
|
55
|
+
const handoff = buildQaReviewHandoff(opts.ticket, opts.builderSummary, snapshot.manifest.diffDigest, loadTicketSetupConfigWithDefaults(opts.builderWorktree).build.validation_checklist);
|
|
60
56
|
const turn = await qa.sendTurn(handoff);
|
|
61
57
|
const status = parseStepStatus(turn.text);
|
|
62
58
|
opts.state.reviews += 1;
|
|
@@ -89,6 +85,15 @@ async function oneReview(opts, cycle) {
|
|
|
89
85
|
await withActivityPhase("cleaning up disposable QA snapshot", () => snapshot.remove());
|
|
90
86
|
}
|
|
91
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
|
+
}
|
|
92
97
|
export async function compactWithRetry(adapter) {
|
|
93
98
|
if (!adapter.compact)
|
|
94
99
|
return { ok: false, error: "native compaction unavailable" };
|
|
@@ -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[];
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { loadTicketsConfig, resolveTicketPaths } from "./config.js";
|
|
3
|
+
import { loadDeliveryConfig } from "./delivery.js";
|
|
4
|
+
import { resolveBlockers } from "./blockers.js";
|
|
5
|
+
import { StateDb, pristineTicketState } from "./stateDb.js";
|
|
6
|
+
import { loadTickets } from "./ticketLoader.js";
|
|
7
|
+
export function getTicketDetails(projectDir, ticketId, now = new Date()) {
|
|
8
|
+
const config = loadTicketsConfig(projectDir);
|
|
9
|
+
const paths = resolveTicketPaths(config, projectDir);
|
|
10
|
+
if (!existsSync(paths.tickets))
|
|
11
|
+
throw new Error("ticket tracker is not initialized; run `rafi tickets init`");
|
|
12
|
+
const tickets = loadTickets(paths.tickets);
|
|
13
|
+
const definition = tickets.find((ticket) => ticket.id === ticketId);
|
|
14
|
+
if (!definition)
|
|
15
|
+
throw new Error(`Ticket ${ticketId} not found. Run \`rafi tickets queue\` to list tickets.`);
|
|
16
|
+
const db = new StateDb(paths.stateDb);
|
|
17
|
+
try {
|
|
18
|
+
const states = db.getAllStates();
|
|
19
|
+
const state = states.get(ticketId) ?? pristineTicketState(ticketId, now.toISOString());
|
|
20
|
+
const delivery = loadDeliveryConfig(projectDir);
|
|
21
|
+
const units = delivery?.units.filter((unit) => unit.tickets.includes(ticketId)) ?? [];
|
|
22
|
+
const unitIds = new Set(units.map((unit) => unit.id));
|
|
23
|
+
const stacks = delivery?.stacks?.filter((stack) => stack.units.some((unit) => unitIds.has(unit))) ?? [];
|
|
24
|
+
return {
|
|
25
|
+
definition,
|
|
26
|
+
state,
|
|
27
|
+
effective_blockers: resolveBlockers(definition, states),
|
|
28
|
+
validation_history: db.getValidationSnapshots(ticketId),
|
|
29
|
+
events: db.getTicketEvents(ticketId),
|
|
30
|
+
delivery: { units, stacks },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
db.close();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function formatTicketDetails(details) {
|
|
38
|
+
const lines = [`${details.definition.id}: ${details.definition.title}`, "", "Canonical ticket"];
|
|
39
|
+
const preferred = [
|
|
40
|
+
"id", "order", "title", "area", "priority", "size", "risk", "summary", "depends_on",
|
|
41
|
+
"acceptance", "required_tests", "likely_files", "rollback", "notes", "external_refs",
|
|
42
|
+
"source_refs", "plan_ref", "supersedes", "superseded_by",
|
|
43
|
+
];
|
|
44
|
+
const keys = [...preferred.filter((key) => key in details.definition), ...Object.keys(details.definition).filter((key) => !preferred.includes(key)).sort()];
|
|
45
|
+
for (const key of keys)
|
|
46
|
+
lines.push(...formatField(key, details.definition[key]));
|
|
47
|
+
lines.push("", "Active state");
|
|
48
|
+
for (const [key, value] of Object.entries(details.state))
|
|
49
|
+
lines.push(...formatField(key, parseJsonField(key, value)));
|
|
50
|
+
lines.push(...formatField("effective_blockers", details.effective_blockers));
|
|
51
|
+
lines.push("", "Active validation and evidence");
|
|
52
|
+
lines.push(...formatField("validation_result", details.state.validation_result));
|
|
53
|
+
lines.push(...formatField("validation_commands", details.state.validation_commands));
|
|
54
|
+
lines.push(...formatField("validation_notes", details.state.validation_notes));
|
|
55
|
+
lines.push(...formatField("evidence", details.state.evidence));
|
|
56
|
+
lines.push("", "Historical validation");
|
|
57
|
+
lines.push(...formatRecords(details.validation_history));
|
|
58
|
+
lines.push("", "Delivery");
|
|
59
|
+
lines.push(...formatField("units", details.delivery.units));
|
|
60
|
+
lines.push(...formatField("stacks", details.delivery.stacks));
|
|
61
|
+
lines.push("", "History");
|
|
62
|
+
lines.push(...formatRecords(details.events));
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
function formatField(label, value) {
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
if (value.length === 0)
|
|
68
|
+
return [`${label}: (none)`];
|
|
69
|
+
if (value.every((item) => typeof item !== "object" || item === null)) {
|
|
70
|
+
return [`${label}:`, ...value.map((item) => ` - ${String(item)}`)];
|
|
71
|
+
}
|
|
72
|
+
return [`${label}:`, ...JSON.stringify(value, null, 2).split("\n").map((line) => ` ${line}`)];
|
|
73
|
+
}
|
|
74
|
+
if (value && typeof value === "object") {
|
|
75
|
+
return [`${label}:`, ...JSON.stringify(value, null, 2).split("\n").map((line) => ` ${line}`)];
|
|
76
|
+
}
|
|
77
|
+
const rendered = value === undefined || value === null || value === "" ? "(none)" : String(value);
|
|
78
|
+
if (!rendered.includes("\n"))
|
|
79
|
+
return [`${label}: ${rendered}`];
|
|
80
|
+
return [`${label}:`, ...rendered.split("\n").map((line) => ` ${line}`)];
|
|
81
|
+
}
|
|
82
|
+
function formatRecords(records) {
|
|
83
|
+
return records.length ? JSON.stringify(records, null, 2).split("\n").map((line) => ` ${line}`) : [" (none)"];
|
|
84
|
+
}
|
|
85
|
+
function parseJsonField(key, value) {
|
|
86
|
+
if (key !== "blocked_by_json" || typeof value !== "string")
|
|
87
|
+
return value;
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(value);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DeliveryConfig } from "./delivery.js";
|
|
2
|
+
import type { TicketDef } from "./ticketSchema.js";
|
|
3
|
+
import type { TicketState } from "./stateDb.js";
|
|
4
|
+
export type TicketIneligibilityCode = "missing" | "terminal" | "outside-window" | "dependencies" | "explicit-blocker" | "delivery-constraint" | "active-lease" | "resumable-owner";
|
|
5
|
+
export interface TicketEligibility {
|
|
6
|
+
ticket?: TicketDef;
|
|
7
|
+
eligible: boolean;
|
|
8
|
+
recommended: boolean;
|
|
9
|
+
blockers: Array<{
|
|
10
|
+
code: TicketIneligibilityCode;
|
|
11
|
+
detail: string;
|
|
12
|
+
tickets?: string[];
|
|
13
|
+
}>;
|
|
14
|
+
eligibleDependencies: TicketDef[];
|
|
15
|
+
}
|
|
16
|
+
export interface EligibilityContext {
|
|
17
|
+
tickets: TicketDef[];
|
|
18
|
+
states: Map<string, TicketState>;
|
|
19
|
+
implementationLimit: number;
|
|
20
|
+
delivery?: DeliveryConfig;
|
|
21
|
+
activeLease?: {
|
|
22
|
+
runId: string;
|
|
23
|
+
};
|
|
24
|
+
allowOwnedResume?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function evaluateTicketEligibility(context: EligibilityContext, ticketId: string): TicketEligibility;
|
|
27
|
+
export declare function eligibleTickets(context: EligibilityContext): TicketDef[];
|
|
28
|
+
export declare function implementationWindow(tickets: TicketDef[], states: Map<string, TicketState>, limit: number): TicketDef[];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { resolveBlockers } from "./blockers.js";
|
|
2
|
+
export function evaluateTicketEligibility(context, ticketId) {
|
|
3
|
+
const ticket = context.tickets.find((candidate) => candidate.id === ticketId);
|
|
4
|
+
if (!ticket)
|
|
5
|
+
return { eligible: false, recommended: false, blockers: [{ code: "missing", detail: `ticket ${ticketId} does not exist` }], eligibleDependencies: [] };
|
|
6
|
+
const state = context.states.get(ticket.id);
|
|
7
|
+
const blockers = [];
|
|
8
|
+
if (["done", "canceled", "obsolete"].includes(state?.status ?? "planned"))
|
|
9
|
+
blockers.push({ code: "terminal", detail: `ticket is ${state?.status}` });
|
|
10
|
+
const window = implementationWindow(context.tickets, context.states, context.implementationLimit);
|
|
11
|
+
if (!window.some((candidate) => candidate.id === ticket.id))
|
|
12
|
+
blockers.push({ code: "outside-window", detail: `ticket is outside the ${context.implementationLimit}-ticket implementation window` });
|
|
13
|
+
const unfinishedDependencies = ticket.depends_on.filter((id) => context.states.get(id)?.status !== "done");
|
|
14
|
+
if (unfinishedDependencies.length)
|
|
15
|
+
blockers.push({ code: "dependencies", detail: `unfinished dependencies: ${unfinishedDependencies.join(", ")}`, tickets: unfinishedDependencies });
|
|
16
|
+
const explicit = resolveBlockers(ticket, context.states).filter((id) => !unfinishedDependencies.includes(id));
|
|
17
|
+
if (explicit.length)
|
|
18
|
+
blockers.push({ code: "explicit-blocker", detail: `explicit blockers: ${explicit.join(", ")}`, tickets: explicit });
|
|
19
|
+
const deliveryReason = deliveryConstraint(ticket.id, context.delivery, context.states);
|
|
20
|
+
if (deliveryReason)
|
|
21
|
+
blockers.push({ code: "delivery-constraint", detail: deliveryReason });
|
|
22
|
+
if (context.activeLease)
|
|
23
|
+
blockers.push({ code: "active-lease", detail: `project has an active workflow lease for run ${context.activeLease.runId}` });
|
|
24
|
+
if (!context.allowOwnedResume && (state?.status === "in_progress" || state?.owner))
|
|
25
|
+
blockers.push({ code: "resumable-owner", detail: `ticket has resumable ownership${state.owner ? ` by ${state.owner}` : ""}` });
|
|
26
|
+
const eligibleDependencies = unfinishedDependencies
|
|
27
|
+
.map((id) => context.tickets.find((candidate) => candidate.id === id))
|
|
28
|
+
.filter((candidate) => Boolean(candidate))
|
|
29
|
+
.filter((candidate) => evaluateTicketEligibility({ ...context, activeLease: undefined }, candidate.id).eligible)
|
|
30
|
+
.sort(ticketOrder);
|
|
31
|
+
const ranked = eligibleTickets({ ...context, activeLease: undefined });
|
|
32
|
+
return { ticket, eligible: blockers.length === 0, recommended: ranked[0]?.id === ticket.id, blockers, eligibleDependencies };
|
|
33
|
+
}
|
|
34
|
+
export function eligibleTickets(context) {
|
|
35
|
+
return implementationWindow(context.tickets, context.states, context.implementationLimit)
|
|
36
|
+
.filter((ticket) => {
|
|
37
|
+
const state = context.states.get(ticket.id);
|
|
38
|
+
if (["done", "canceled", "obsolete", "in_progress"].includes(state?.status ?? "planned") || state?.owner)
|
|
39
|
+
return false;
|
|
40
|
+
if (resolveBlockers(ticket, context.states).length)
|
|
41
|
+
return false;
|
|
42
|
+
return !deliveryConstraint(ticket.id, context.delivery, context.states);
|
|
43
|
+
})
|
|
44
|
+
.sort(ticketOrder);
|
|
45
|
+
}
|
|
46
|
+
export function implementationWindow(tickets, states, limit) {
|
|
47
|
+
return tickets.filter((ticket) => !["done", "canceled", "obsolete"].includes(states.get(ticket.id)?.status ?? "planned")).sort(ticketOrder).slice(0, limit);
|
|
48
|
+
}
|
|
49
|
+
function ticketOrder(a, b) {
|
|
50
|
+
return a.order - b.order || a.id.localeCompare(b.id);
|
|
51
|
+
}
|
|
52
|
+
function deliveryConstraint(ticketId, delivery, states) {
|
|
53
|
+
if (!delivery)
|
|
54
|
+
return undefined;
|
|
55
|
+
const unit = delivery.units.find((candidate) => candidate.tickets.includes(ticketId));
|
|
56
|
+
if (!unit)
|
|
57
|
+
return undefined;
|
|
58
|
+
const stack = delivery.stacks?.find((candidate) => candidate.units.includes(unit.id));
|
|
59
|
+
if (!stack)
|
|
60
|
+
return undefined;
|
|
61
|
+
const firstUnfinishedUnit = stack.units.find((unitId) => {
|
|
62
|
+
const candidate = delivery.units.find((item) => item.id === unitId);
|
|
63
|
+
return candidate?.tickets.some((id) => !["done", "canceled", "obsolete"].includes(states.get(id)?.status ?? "planned"));
|
|
64
|
+
});
|
|
65
|
+
if (firstUnfinishedUnit && firstUnfinishedUnit !== unit.id)
|
|
66
|
+
return `delivery stack ${stack.id} must continue with unit ${firstUnfinishedUnit}`;
|
|
67
|
+
const firstUnfinishedTicket = unit.tickets.find((id) => !["done", "canceled", "obsolete"].includes(states.get(id)?.status ?? "planned"));
|
|
68
|
+
if (firstUnfinishedTicket && firstUnfinishedTicket !== ticketId && unit.dependency_mode === "stack")
|
|
69
|
+
return `delivery unit ${unit.id} must continue with ${firstUnfinishedTicket}`;
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type TicketStatus } from "./stateDb.js";
|
|
2
|
+
export type TicketResetScope = "all" | "completed-and-unfinished" | "unfinished";
|
|
3
|
+
export interface TicketResetPreviewRow {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
status: TicketStatus;
|
|
7
|
+
cleared: string[];
|
|
8
|
+
}
|
|
9
|
+
export interface TicketResetPreview {
|
|
10
|
+
scope: TicketResetScope | "single" | "run";
|
|
11
|
+
tickets: TicketResetPreviewRow[];
|
|
12
|
+
relatedRuns: Array<{
|
|
13
|
+
runId: string;
|
|
14
|
+
status: string;
|
|
15
|
+
tickets: string[];
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
export declare function previewTicketReset(projectDir: string, target: string | TicketResetScope | string[]): TicketResetPreview;
|
|
19
|
+
export declare function resetTickets(projectDir: string, target: string | TicketResetScope | string[], actor?: string, now?: Date, hooks?: {
|
|
20
|
+
beforePublish?: () => void;
|
|
21
|
+
resetId?: string;
|
|
22
|
+
}): {
|
|
23
|
+
resetId: string;
|
|
24
|
+
tickets: string[];
|
|
25
|
+
};
|
|
26
|
+
/** Finish or roll back a reset publication left incomplete by process interruption. */
|
|
27
|
+
export declare function recoverTicketResetPublications(projectDir: string): string[];
|