ai-foreman 1.6.2 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/adapters/claude.d.ts +5 -1
- package/dist/adapters/claude.js +41 -0
- package/dist/adapters/codex.d.ts +26 -28
- package/dist/adapters/codex.js +209 -148
- package/dist/adapters/recovering.d.ts +3 -1
- package/dist/adapters/recovering.js +2 -0
- package/dist/adapters/types.d.ts +29 -0
- package/dist/branch/planner.js +1 -1
- package/dist/branch/runner.d.ts +6 -0
- package/dist/branch/runner.js +149 -16
- package/dist/buildRuns.d.ts +9 -2
- package/dist/buildRuns.js +110 -7
- package/dist/cli/start.d.ts +4 -0
- package/dist/cli/start.js +283 -28
- package/dist/cli/status.js +25 -2
- package/dist/cli/tickets.d.ts +1 -0
- package/dist/cli/tickets.js +67 -27
- package/dist/foreman.d.ts +18 -3
- package/dist/foreman.js +112 -5
- package/dist/log.d.ts +1 -1
- package/dist/operationRecovery.d.ts +25 -0
- package/dist/operationRecovery.js +46 -0
- package/dist/outcomes.d.ts +16 -0
- package/dist/outcomes.js +27 -0
- package/dist/qaReview.d.ts +52 -0
- package/dist/qaReview.js +103 -0
- package/dist/qaSnapshot.d.ts +19 -0
- package/dist/qaSnapshot.js +88 -0
- package/dist/sessionLifecycle.d.ts +37 -0
- package/dist/sessionLifecycle.js +104 -0
- package/dist/statusReporter.d.ts +40 -0
- package/dist/statusReporter.js +31 -0
- package/dist/ticketPopulation.d.ts +47 -0
- package/dist/ticketPopulation.js +205 -0
- package/dist/tickets/blockers.d.ts +1 -1
- package/dist/tickets/blockers.js +2 -0
- package/dist/tickets/commands.d.ts +1 -0
- package/dist/tickets/commands.js +30 -1
- package/dist/tickets/delivery.d.ts +36 -0
- package/dist/tickets/delivery.js +204 -4
- package/dist/tickets/queue.d.ts +3 -0
- package/dist/tickets/queue.js +50 -1
- package/dist/tickets/stateDb.d.ts +2 -1
- package/dist/tickets/stateDb.js +24 -1
- package/dist/tickets/ticketSchema.d.ts +44 -0
- package/dist/tickets/ticketSchema.js +10 -0
- package/dist/tickets/validate.js +1 -1
- package/dist/workflowDb.d.ts +113 -0
- package/dist/workflowDb.js +261 -0
- package/package.json +16 -3
package/dist/cli/tickets.js
CHANGED
|
@@ -7,15 +7,19 @@ import { loadConfig } from "../config.js";
|
|
|
7
7
|
import { Log } from "../log.js";
|
|
8
8
|
import { Foreman } from "../foreman.js";
|
|
9
9
|
import { printEvents } from "./events.js";
|
|
10
|
-
import { cmdInit, cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdCancel, cmdDiscover, cmdAcceptFutureWork, cmdReorder, cmdRender, cmdValidate, cmdQueue, cmdArchive, cmdReview, } from "../tickets/commands.js";
|
|
10
|
+
import { cmdInit, cmdUpdate, cmdComplete, cmdBlock, cmdUnblock, cmdCancel, cmdDiscover, cmdAcceptFutureWork, cmdReorder, cmdRender, cmdValidate, cmdQueue, cmdStackQueue, cmdArchive, cmdReview, } from "../tickets/commands.js";
|
|
11
|
+
import { loadDeliveryConfig } from "../tickets/delivery.js";
|
|
11
12
|
import { DEFAULT_TICKETS_CONFIG, isTicketsInitialized, loadTicketsConfig } from "../tickets/config.js";
|
|
12
13
|
import { validateDocsRoot } from "../tickets/config.js";
|
|
13
14
|
import { importExternalSources, importFromMarkdown, validateExternalSourceAccess } from "../tickets/importer.js";
|
|
14
15
|
import { formatValidationIssues } from "../tickets/validate.js";
|
|
15
|
-
import { assertEffortLevel, createRoleBuilder, makeLogPath, } from "../agentRun.js";
|
|
16
|
+
import { assertEffortLevel, createRoleBuilder, makeLogPath, readOnlyPermissionConfig, } from "../agentRun.js";
|
|
16
17
|
import { DEFAULT_TICKET_SETUP, configuredPlanningSources, defaultAppName, ensureRafiConfigForTicketSetup, externalSources, hasTicketSetupConfig, loadTicketSetupConfig, loadTicketSetupConfigWithDefaults, localSourcePaths, mergeTicketSetup, recommendedBuildDefaults, saveTicketSetupConfig, urlSources, } from "../tickets/setupConfig.js";
|
|
17
18
|
import { fetchAndSnapshotUrl, snapshotExternalLocalFile } from "../tickets/sourceFetch.js";
|
|
18
19
|
import { checkpointInterview, completeInterview, createInterviewRecord, failInterview, } from "../interviews.js";
|
|
20
|
+
import { applyTicketPopulation, authorizeTicketRetirements, extractTicketPopulationProposal, materializeTicketPopulation, TICKET_POPULATION_PROPOSAL_END, TICKET_POPULATION_PROPOSAL_START } from "../ticketPopulation.js";
|
|
21
|
+
import { loadTickets } from "../tickets/ticketLoader.js";
|
|
22
|
+
import { resolveTicketPaths } from "../tickets/config.js";
|
|
19
23
|
function fail(msg) {
|
|
20
24
|
console.error(`foreman tickets: ${msg}`);
|
|
21
25
|
process.exit(1);
|
|
@@ -59,30 +63,28 @@ Goal:
|
|
|
59
63
|
- Do not implement product/code changes. Only update ticket-tracker files.
|
|
60
64
|
${sourceHintBlock}
|
|
61
65
|
|
|
62
|
-
Before
|
|
66
|
+
Before proposing, read these tracker control files:
|
|
63
67
|
- .tickets/config.yaml
|
|
64
68
|
- .tickets/tickets.yaml
|
|
65
69
|
- .tickets/tracker-rules.md
|
|
66
70
|
- ${progressDoc} if it exists
|
|
67
71
|
|
|
68
|
-
Then inspect the repository for existing planning sources
|
|
72
|
+
Then inspect the repository for existing planning sources, beginning with the approved structured Rafi plan. You are proposal-only: do not edit any file, run tracker mutation commands, allocate final ticket IDs, or write delivery configuration. Rafi validates and performs all writes after approval.
|
|
69
73
|
|
|
70
|
-
|
|
71
|
-
-
|
|
74
|
+
Return ticket content using Foreman's schema:
|
|
75
|
+
- slice_ref: the exact approved plan slice reference. Do not allocate ticket IDs.
|
|
72
76
|
- order: unique numeric implementation order. Use gaps like 1000, 2000, 3000.
|
|
73
77
|
- title, area, priority, size, risk, depends_on, summary, acceptance, required_tests, likely_files, rollback, notes.
|
|
74
78
|
- Keep dependencies, acceptance criteria, testing expectations, file hints, risk notes, and implementation notes from the source material.
|
|
75
79
|
- Do not store mutable status/progress fields in .tickets/tickets.yaml.
|
|
76
|
-
-
|
|
80
|
+
- Dependencies must reference slice_ref values. Include the exact IDs of existing populated tickets whose removed slices should become obsolete.
|
|
77
81
|
|
|
78
82
|
If source content does not cleanly map to the new schema, ask for guidance instead of guessing. Use:
|
|
79
83
|
STEP_STATUS: needs_input | question="..." choices="..."
|
|
80
84
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
-
|
|
84
|
-
- Fix validation errors if possible.
|
|
85
|
-
- Triple-check that every source ticket/task is represented exactly once and no important detail was dropped.
|
|
85
|
+
Output one JSON object between ${TICKET_POPULATION_PROPOSAL_START} and ${TICKET_POPULATION_PROPOSAL_END} with shape:
|
|
86
|
+
{"version":1,"plan_id":"approved plan ID","revision":1,"tickets":[{"slice_ref":"slc_...","title":"...","area":"...","priority":"P1","size":"S","risk":"Low","summary":"...","acceptance":["..."],"required_tests":["..."],"likely_files":["..."],"depends_on":["slice_ref"],"rollback":null,"notes":null}],"retirements":["exact existing ticket ID"]}
|
|
87
|
+
Triple-check that every approved slice appears exactly once.
|
|
86
88
|
|
|
87
89
|
End with exactly one marker line as the final non-empty line:
|
|
88
90
|
STEP_STATUS: done | summary="populated Foreman tickets from existing project ticket sources"
|
|
@@ -134,6 +136,8 @@ export function buildPopulateAgentRunOptions(opts) {
|
|
|
134
136
|
yes: opts.yes,
|
|
135
137
|
label: "tickets populate",
|
|
136
138
|
log: opts.log,
|
|
139
|
+
permissionConfig: readOnlyPermissionConfig(),
|
|
140
|
+
sandboxMode: "read-only",
|
|
137
141
|
};
|
|
138
142
|
}
|
|
139
143
|
async function cmdSetupInitCli(opts) {
|
|
@@ -713,11 +717,12 @@ export async function cmdPopulateCli(opts) {
|
|
|
713
717
|
return;
|
|
714
718
|
}
|
|
715
719
|
}
|
|
720
|
+
const structuredPlan = loadPopulationPlan(dir, sourceHints);
|
|
716
721
|
if (!opts.yes) {
|
|
717
722
|
const action = await select({
|
|
718
|
-
message: "
|
|
723
|
+
message: "Ask the read-only Ticket Maker for a structured population proposal?",
|
|
719
724
|
options: [
|
|
720
|
-
{ value: "proceed", label: "Proceed -
|
|
725
|
+
{ value: "proceed", label: "Proceed - generate proposal only" },
|
|
721
726
|
{ value: "cancel", label: "Cancel" },
|
|
722
727
|
],
|
|
723
728
|
});
|
|
@@ -772,18 +777,29 @@ export async function cmdPopulateCli(opts) {
|
|
|
772
777
|
console.error(`foreman tickets: needs human — ${turn.status.error ?? "builder did not emit done"}`);
|
|
773
778
|
process.exit(2);
|
|
774
779
|
}
|
|
775
|
-
|
|
776
|
-
const
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
780
|
+
const existing = loadTickets(resolveTicketPaths(ticketsConfig, dir).tickets);
|
|
781
|
+
const proposal = extractTicketPopulationProposal(turn.result.text);
|
|
782
|
+
const materialized = materializeTicketPopulation(proposal, structuredPlan, existing);
|
|
783
|
+
let retirementsConfirmed = false;
|
|
784
|
+
if (!opts.yes) {
|
|
785
|
+
console.log(`foreman tickets: proposal maps ${materialized.sliceToTicket.size} slice(s) and retires ${materialized.retirements.join(", ") || "none"}`);
|
|
786
|
+
const approved = await confirm({ message: "Apply this exact ticket and delivery proposal?", initialValue: false });
|
|
787
|
+
if (isCancel(approved) || !approved) {
|
|
788
|
+
console.log("foreman tickets: cancelled; no files or tracker state changed");
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (materialized.retirements.length) {
|
|
792
|
+
const retirement = await confirm({ message: `Mark these removed-slice tickets obsolete: ${materialized.retirements.join(", ")}?`, initialValue: false });
|
|
793
|
+
if (isCancel(retirement) || !retirement) {
|
|
794
|
+
console.log("foreman tickets: cancelled; no files or tracker state changed");
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
retirementsConfirmed = true;
|
|
798
|
+
}
|
|
785
799
|
}
|
|
786
|
-
|
|
800
|
+
authorizeTicketRetirements(materialized.retirements, { interactiveConfirmed: retirementsConfirmed, authorizedIds: opts.authorizeRetire, computerRun: Boolean(opts.yes) });
|
|
801
|
+
const applied = applyTicketPopulation(dir, materialized);
|
|
802
|
+
console.log(`foreman tickets: populated atomically (run ${applied.runId}, transaction ${applied.transactionId})`);
|
|
787
803
|
}
|
|
788
804
|
catch (err) {
|
|
789
805
|
await builder?.close().catch(() => { });
|
|
@@ -806,6 +822,17 @@ async function prepareDocumentSources(projectDir, sources) {
|
|
|
806
822
|
}
|
|
807
823
|
return unique(prepared);
|
|
808
824
|
}
|
|
825
|
+
function loadPopulationPlan(projectDir, sources) {
|
|
826
|
+
for (const source of sources) {
|
|
827
|
+
const candidate = source.endsWith(".json") ? resolve(projectDir, source) : source.endsWith(".md") ? resolve(projectDir, source.replace(/\.md$/, ".json")) : undefined;
|
|
828
|
+
if (!candidate || !existsSync(candidate))
|
|
829
|
+
continue;
|
|
830
|
+
const plan = JSON.parse(readFileSync(candidate, "utf8"));
|
|
831
|
+
if (plan.version === 1 && plan.plan_id && Number.isInteger(plan.revision) && plan.content_digest)
|
|
832
|
+
return plan;
|
|
833
|
+
}
|
|
834
|
+
throw new Error("ticket population requires the approved paired structured plan (normally docs/rafi-plan.json); run `rafi plan --validate` first");
|
|
835
|
+
}
|
|
809
836
|
export function buildTicketsCommand() {
|
|
810
837
|
const tickets = new Command("tickets").description("Manage the structured ticket tracker for a project.");
|
|
811
838
|
// ── setup:init / setup:update ─────────────────────────────────────────────
|
|
@@ -915,7 +942,8 @@ export function buildTicketsCommand() {
|
|
|
915
942
|
.option("--effort <level>", "reasoning effort level (low|medium|high|xhigh)")
|
|
916
943
|
.option("--sources <paths...>", "source hint files, folders, or globs to check first")
|
|
917
944
|
.option("--fast", "fast mode - lower latency")
|
|
918
|
-
.option("-
|
|
945
|
+
.option("--authorize-retire <ids...>", "exact ticket IDs authorized to become obsolete in computer-run mode")
|
|
946
|
+
.option("-y, --yes", "computer-run approval; retirements still require --authorize-retire exact IDs")
|
|
919
947
|
.action(async (opts) => {
|
|
920
948
|
try {
|
|
921
949
|
await cmdPopulateCli(opts);
|
|
@@ -1233,9 +1261,21 @@ export function buildTicketsCommand() {
|
|
|
1233
1261
|
.description("Print the ticket queue to stdout.")
|
|
1234
1262
|
.option("-p, --project <dir>", "project directory (default: cwd)")
|
|
1235
1263
|
.option("--limit <n>", "override view limit")
|
|
1264
|
+
.option("--refresh", "query GitHub/GitLab and refresh cached stack review state")
|
|
1236
1265
|
.action((opts) => {
|
|
1237
1266
|
try {
|
|
1238
|
-
const
|
|
1267
|
+
const dir = cwd(opts);
|
|
1268
|
+
const delivery = loadDeliveryConfig(dir);
|
|
1269
|
+
if (delivery?.stacks?.length) {
|
|
1270
|
+
const lines = cmdStackQueue(dir, Boolean(opts.refresh));
|
|
1271
|
+
if (!lines.length)
|
|
1272
|
+
console.log("No remaining tickets.");
|
|
1273
|
+
else
|
|
1274
|
+
for (const line of lines)
|
|
1275
|
+
console.log(line);
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
const rows = cmdQueue(dir, opts.limit !== undefined ? Number(opts.limit) : undefined);
|
|
1239
1279
|
if (rows.length === 0) {
|
|
1240
1280
|
console.log("No remaining tickets.");
|
|
1241
1281
|
}
|
package/dist/foreman.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { BuilderAdapter, PermissionHandler, TurnResult } from "./adapters/types.js";
|
|
2
2
|
import type { PermissionPolicy } from "./permissions/policy.js";
|
|
3
3
|
import type { Log } from "./log.js";
|
|
4
|
+
import { type QaNonconvergenceContext, type QaNonconvergenceDecision } from "./qaReview.js";
|
|
5
|
+
import type { SessionStrategy } from "rafi-spec";
|
|
4
6
|
/** Parsed STEP_STATUS marker from a builder's turn. */
|
|
5
7
|
export interface StepStatus {
|
|
6
8
|
kind: "done" | "blocked" | "plan_complete" | "needs_input" | "qa_pass" | "qa_fail" | "unknown";
|
|
@@ -43,20 +45,28 @@ export interface BatchResult {
|
|
|
43
45
|
}
|
|
44
46
|
/** Drives one builder through a batch of N steps via the STEP_STATUS protocol. */
|
|
45
47
|
export declare class Foreman {
|
|
46
|
-
private
|
|
48
|
+
private builder;
|
|
47
49
|
private readonly log;
|
|
48
50
|
private readonly notificationsEnabled;
|
|
49
51
|
private readonly qaEnabled;
|
|
50
52
|
private readonly qaMaxCycles;
|
|
51
53
|
private readonly projectDir?;
|
|
52
54
|
private readonly qaReviewer?;
|
|
55
|
+
private readonly qaFactory?;
|
|
56
|
+
private readonly qaSessionStrategy;
|
|
57
|
+
private readonly builderFactory?;
|
|
58
|
+
private readonly builderSessionStrategy;
|
|
59
|
+
private readonly qaNonconvergence?;
|
|
53
60
|
private readonly ticketsEnabled;
|
|
54
|
-
|
|
61
|
+
private readonly qaStream;
|
|
62
|
+
private builderWorkSessions;
|
|
63
|
+
constructor(builder: BuilderAdapter, log: Log, notificationsEnabled?: boolean, qaEnabled?: boolean, qaMaxCycles?: number, projectDir?: string | undefined, qaReviewer?: BuilderAdapter | undefined, qaFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, qaSessionStrategy?: SessionStrategy, builderFactory?: ((cwd: string, sessionId?: string) => Promise<BuilderAdapter>) | undefined, builderSessionStrategy?: SessionStrategy, qaNonconvergence?: ((context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>) | undefined);
|
|
55
64
|
/**
|
|
56
65
|
* Send one instruction and resolve any needs_input exchanges before returning.
|
|
57
66
|
* The returned result and status always reflect the final (non-needs_input) turn.
|
|
58
67
|
*/
|
|
59
68
|
private doTurn;
|
|
69
|
+
private prepareBuilderBoundary;
|
|
60
70
|
private doTurnWith;
|
|
61
71
|
/** Send a planning turn and return the builder's response text. Does not count toward steps. */
|
|
62
72
|
runPreflight(n: number, ticketsContent?: string): Promise<string>;
|
|
@@ -74,9 +84,14 @@ export declare class Foreman {
|
|
|
74
84
|
*/
|
|
75
85
|
private runQa;
|
|
76
86
|
runQaReview(stepIndex: number): Promise<{
|
|
77
|
-
outcome: "passed" | "blocked" | "needs-human";
|
|
87
|
+
outcome: "passed" | "blocked" | "needs-human" | "waived";
|
|
78
88
|
detail?: string;
|
|
79
89
|
summary?: string;
|
|
80
90
|
}>;
|
|
91
|
+
qaSessionId(): string | undefined;
|
|
92
|
+
builderSessionId(): string | undefined;
|
|
93
|
+
builderAdapter(): BuilderAdapter;
|
|
94
|
+
close(): Promise<void>;
|
|
95
|
+
private ticketForQa;
|
|
81
96
|
runBatch(n: number, trackerPath?: string): Promise<BatchResult>;
|
|
82
97
|
}
|
package/dist/foreman.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { select, isCancel } from "@clack/prompts";
|
|
2
2
|
import { MARKER_SPEC, QA_MARKER_SPEC } from "./markers.js";
|
|
3
3
|
import { fireNotification } from "./notify.js";
|
|
4
|
-
import { isTicketsInitialized } from "./tickets/config.js";
|
|
4
|
+
import { isTicketsInitialized, loadTicketsConfig } from "./tickets/config.js";
|
|
5
5
|
import { cmdUpdate, cmdComplete, cmdBlock, cmdImplementationQueue } from "./tickets/commands.js";
|
|
6
|
+
import { loadTickets } from "./tickets/ticketLoader.js";
|
|
7
|
+
import { runIsolatedQa } from "./qaReview.js";
|
|
6
8
|
import { createHash } from "node:crypto";
|
|
7
9
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
8
10
|
import { join, relative } from "node:path";
|
|
@@ -196,8 +198,15 @@ export class Foreman {
|
|
|
196
198
|
qaMaxCycles;
|
|
197
199
|
projectDir;
|
|
198
200
|
qaReviewer;
|
|
201
|
+
qaFactory;
|
|
202
|
+
qaSessionStrategy;
|
|
203
|
+
builderFactory;
|
|
204
|
+
builderSessionStrategy;
|
|
205
|
+
qaNonconvergence;
|
|
199
206
|
ticketsEnabled;
|
|
200
|
-
|
|
207
|
+
qaStream = { reviews: 0, modificationViolations: 0 };
|
|
208
|
+
builderWorkSessions = 0;
|
|
209
|
+
constructor(builder, log, notificationsEnabled = false, qaEnabled = true, qaMaxCycles = 3, projectDir, qaReviewer, qaFactory, qaSessionStrategy = "compact", builderFactory, builderSessionStrategy = "compact", qaNonconvergence) {
|
|
201
210
|
this.builder = builder;
|
|
202
211
|
this.log = log;
|
|
203
212
|
this.notificationsEnabled = notificationsEnabled;
|
|
@@ -205,6 +214,11 @@ export class Foreman {
|
|
|
205
214
|
this.qaMaxCycles = qaMaxCycles;
|
|
206
215
|
this.projectDir = projectDir;
|
|
207
216
|
this.qaReviewer = qaReviewer;
|
|
217
|
+
this.qaFactory = qaFactory;
|
|
218
|
+
this.qaSessionStrategy = qaSessionStrategy;
|
|
219
|
+
this.builderFactory = builderFactory;
|
|
220
|
+
this.builderSessionStrategy = builderSessionStrategy;
|
|
221
|
+
this.qaNonconvergence = qaNonconvergence;
|
|
208
222
|
this.ticketsEnabled = !!(projectDir && isTicketsInitialized(projectDir));
|
|
209
223
|
}
|
|
210
224
|
/**
|
|
@@ -214,9 +228,54 @@ export class Foreman {
|
|
|
214
228
|
async doTurn(instruction) {
|
|
215
229
|
return this.doTurnWith(this.builder, instruction);
|
|
216
230
|
}
|
|
231
|
+
async prepareBuilderBoundary() {
|
|
232
|
+
if (!this.builderFactory || !this.projectDir)
|
|
233
|
+
return;
|
|
234
|
+
if (this.builderSessionStrategy === "fresh") {
|
|
235
|
+
await this.builder.close();
|
|
236
|
+
this.builder = await this.builderFactory(this.projectDir);
|
|
237
|
+
this.log.write("branch-session", { role: "builder", transition: "fresh", workSession: this.builderWorkSessions + 1 });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const priorSession = this.builder.sessionId();
|
|
241
|
+
if (priorSession && this.builder.compact) {
|
|
242
|
+
let error = "";
|
|
243
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
244
|
+
const result = await this.builder.compact().catch((cause) => ({ ok: false, error: cause instanceof Error ? cause.message : String(cause) }));
|
|
245
|
+
if (result.ok) {
|
|
246
|
+
this.log.write("branch-session", { role: "builder", transition: attempt === 1 ? "compacted" : "compaction-retry-succeeded", workSession: this.builderWorkSessions + 1, sessionId: priorSession });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
error = result.error ?? "native compaction failed";
|
|
250
|
+
if (attempt === 1)
|
|
251
|
+
this.log.write("branch-session", { role: "builder", transition: "compaction-retry", detail: error });
|
|
252
|
+
}
|
|
253
|
+
await this.builder.close();
|
|
254
|
+
this.builder = await this.builderFactory(this.projectDir);
|
|
255
|
+
this.log.write("branch-session", { role: "builder", transition: "compaction-fallback-fresh", continuityLost: true, detail: error });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
await this.builder.close();
|
|
259
|
+
this.builder = await this.builderFactory(this.projectDir);
|
|
260
|
+
this.log.write("branch-session", { role: "builder", transition: "missing-session-fresh", continuityLost: true });
|
|
261
|
+
}
|
|
217
262
|
async doTurnWith(adapter, instruction) {
|
|
218
263
|
let result = await adapter.sendTurn(instruction);
|
|
219
264
|
let status = parseStepStatus(result.text);
|
|
265
|
+
if (status.kind === "unknown") {
|
|
266
|
+
if (!status.error && looksLikeQuestion(result.text)) {
|
|
267
|
+
status = { kind: "needs_input", question: lastLine(result.text), choices: ["Continue", "Cancel"] };
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
const qa = /\bQA\b|qa_pass|qa_fail/i.test(instruction);
|
|
271
|
+
result = await adapter.sendTurn(qa
|
|
272
|
+
? "Protocol correction only: based on the review already completed, return exactly one final STEP_STATUS: qa_pass or STEP_STATUS: qa_fail marker. Do not repeat QA, tests, tools, or compaction."
|
|
273
|
+
: "Protocol correction only: based on the work already completed, return exactly one final STEP_STATUS: done, plan_complete, blocked, or needs_input marker. Do not repeat implementation, tools, or compaction.");
|
|
274
|
+
status = parseStepStatus(result.text);
|
|
275
|
+
if (status.kind === "unknown")
|
|
276
|
+
status = { ...status, error: `protocol correction exhausted: ${status.error ?? "missing final marker"}` };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
220
279
|
while (status.kind === "needs_input") {
|
|
221
280
|
const question = status.question ?? "The builder has a question";
|
|
222
281
|
const choices = status.choices?.length
|
|
@@ -272,6 +331,31 @@ export class Foreman {
|
|
|
272
331
|
* QA turns are free — they do not advance the step counter.
|
|
273
332
|
*/
|
|
274
333
|
async runQa(stepIndex) {
|
|
334
|
+
if (this.projectDir && this.qaFactory) {
|
|
335
|
+
const review = await runIsolatedQa({
|
|
336
|
+
ticket: this.ticketForQa(stepIndex),
|
|
337
|
+
builderWorktree: this.projectDir,
|
|
338
|
+
builderSummary: `Builder completed step ${stepIndex}`,
|
|
339
|
+
qaStrategy: this.qaSessionStrategy,
|
|
340
|
+
state: this.qaStream,
|
|
341
|
+
createQa: this.qaFactory,
|
|
342
|
+
maxCycles: this.qaMaxCycles,
|
|
343
|
+
fix: async (issues) => {
|
|
344
|
+
await this.prepareBuilderBoundary();
|
|
345
|
+
this.builderWorkSessions += 1;
|
|
346
|
+
const fix = await this.doTurn(buildQaFixInstruction(issues));
|
|
347
|
+
this.log.write("qa-fix", { stepIndex, statusKind: fix.status.kind, costUsd: fix.result.costUsd, isError: fix.result.isError });
|
|
348
|
+
return fix.result.isError || fix.status.kind !== "done"
|
|
349
|
+
? { ok: false, detail: fix.status.reason ?? fix.status.error ?? fix.result.text.slice(0, 200) }
|
|
350
|
+
: { ok: true };
|
|
351
|
+
},
|
|
352
|
+
evidence: ({ cycle, outcome, detail, qaDiff }) => this.log.write("qa", { stepIndex, cycle, outcome, detail, qaDiff, disposable: true }),
|
|
353
|
+
onNonconvergence: this.qaNonconvergence,
|
|
354
|
+
});
|
|
355
|
+
if (review.outcome === "nonconverged")
|
|
356
|
+
return { outcome: "needs-human", detail: review.detail };
|
|
357
|
+
return { outcome: review.outcome, detail: review.detail, summary: review.summary };
|
|
358
|
+
}
|
|
275
359
|
for (let cycle = 1; cycle <= this.qaMaxCycles; cycle++) {
|
|
276
360
|
const before = this.qaReviewer && this.projectDir ? fingerprintProtectedTree(this.projectDir) : undefined;
|
|
277
361
|
const qa = await this.doTurnWith(this.qaReviewer ?? this.builder, buildQaInstruction());
|
|
@@ -335,6 +419,24 @@ export class Foreman {
|
|
|
335
419
|
async runQaReview(stepIndex) {
|
|
336
420
|
return this.runQa(stepIndex);
|
|
337
421
|
}
|
|
422
|
+
qaSessionId() { return this.qaStream.sessionId ?? this.qaReviewer?.sessionId(); }
|
|
423
|
+
builderSessionId() { return this.builder.sessionId(); }
|
|
424
|
+
builderAdapter() { return this.builder; }
|
|
425
|
+
async close() { await this.builder.close(); }
|
|
426
|
+
ticketForQa(stepIndex) {
|
|
427
|
+
if (this.projectDir && this.ticketsEnabled) {
|
|
428
|
+
const config = loadTicketsConfig(this.projectDir);
|
|
429
|
+
const active = cmdImplementationQueue(this.projectDir).find((row) => row.status === "next" || row.status === "in_progress");
|
|
430
|
+
const ticket = loadTickets(join(this.projectDir, config.paths.tickets)).find((candidate) => candidate.id === active?.ticket);
|
|
431
|
+
if (ticket)
|
|
432
|
+
return ticket;
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
id: `STEP-${stepIndex}`, order: stepIndex, title: `Implementation step ${stepIndex}`, area: "project",
|
|
436
|
+
priority: "P2", size: "M", risk: "Low", depends_on: [], summary: `Review implementation step ${stepIndex}`,
|
|
437
|
+
acceptance: ["The requested implementation step is complete"], required_tests: ["Run the relevant project validation"], likely_files: [],
|
|
438
|
+
};
|
|
439
|
+
}
|
|
338
440
|
async runBatch(n, trackerPath) {
|
|
339
441
|
this.log.write("batch-start", { requested: n, agent: this.builder.agent });
|
|
340
442
|
let completed = 0;
|
|
@@ -365,6 +467,9 @@ export class Foreman {
|
|
|
365
467
|
const instruction = i === 1
|
|
366
468
|
? buildPrimer(n, trackerPath, this.ticketsEnabled)
|
|
367
469
|
: buildNextStepInstruction(i, n);
|
|
470
|
+
if (i > 1)
|
|
471
|
+
await this.prepareBuilderBoundary();
|
|
472
|
+
this.builderWorkSessions += 1;
|
|
368
473
|
const { result, status } = await this.doTurn(instruction);
|
|
369
474
|
this.log.write("step", {
|
|
370
475
|
index: i,
|
|
@@ -383,6 +488,7 @@ export class Foreman {
|
|
|
383
488
|
}
|
|
384
489
|
if (status.kind === "done" || status.kind === "plan_complete") {
|
|
385
490
|
let qaSummary;
|
|
491
|
+
let qaWaived = false;
|
|
386
492
|
if (this.qaEnabled) {
|
|
387
493
|
const qa = await this.runQa(i);
|
|
388
494
|
if (qa.outcome === "blocked") {
|
|
@@ -395,6 +501,7 @@ export class Foreman {
|
|
|
395
501
|
detail = qa.detail;
|
|
396
502
|
break;
|
|
397
503
|
}
|
|
504
|
+
qaWaived = qa.outcome === "waived";
|
|
398
505
|
qaSummary = qa.summary;
|
|
399
506
|
}
|
|
400
507
|
// Update ticket state only after QA has passed, so generated tracker
|
|
@@ -406,12 +513,12 @@ export class Foreman {
|
|
|
406
513
|
cmdComplete(this.projectDir, ticketId, {
|
|
407
514
|
actor: "foreman",
|
|
408
515
|
summary: status.summary ?? `Step ${i} complete`,
|
|
409
|
-
validationResult: this.qaEnabled ? "passed" : "not_applicable",
|
|
516
|
+
validationResult: qaWaived ? "failed" : this.qaEnabled ? "passed" : "not_applicable",
|
|
410
517
|
validationNotes: this.qaEnabled
|
|
411
|
-
? "Foreman QA emitted qa_pass"
|
|
518
|
+
? qaWaived ? "User explicitly waived unresolved QA failures" : "Foreman QA emitted qa_pass"
|
|
412
519
|
: "Foreman QA disabled for this run",
|
|
413
520
|
evidence: this.qaEnabled
|
|
414
|
-
? (qaSummary ?? "Foreman QA emitted qa_pass")
|
|
521
|
+
? (qaSummary ?? (qaWaived ? "Unresolved QA issues preserved in run evidence" : "Foreman QA emitted qa_pass"))
|
|
415
522
|
: undefined,
|
|
416
523
|
});
|
|
417
524
|
}
|
package/dist/log.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** A single structured log record. */
|
|
2
2
|
export interface LogRecord {
|
|
3
3
|
ts: string;
|
|
4
|
-
event: "batch-start" | "batch-end" | "branch-plan" | "branch-start" | "branch-session" | "branch-resume" | "branch-issue" | "branch-complete" | "branch-push" | "github-readiness-failed" | "gitlab-readiness-failed" | "pr-created" | "pr-existing" | "pr-failed" | "pr-auto-merge-enabled" | "pr-auto-merge-failed" | "mr-created" | "mr-existing" | "mr-failed" | "mr-auto-merge-enabled" | "mr-auto-merge-failed" | "branch-auto-merge-wait" | "branch-direct-merge" | "step" | "permission" | "escalation" | "needs_input" | "preflight" | "rafi-plan" | "ticket-populate" | "uninstall-proposal" | "qa" | "qa-fix" | "qa-protected-files-changed" | "error";
|
|
4
|
+
event: "batch-start" | "batch-end" | "branch-plan" | "branch-start" | "branch-session" | "branch-resume" | "branch-issue" | "branch-complete" | "branch-push" | "github-readiness-failed" | "gitlab-readiness-failed" | "pr-created" | "pr-existing" | "pr-failed" | "pr-auto-merge-enabled" | "pr-auto-merge-failed" | "mr-created" | "mr-existing" | "mr-failed" | "mr-auto-merge-enabled" | "mr-auto-merge-failed" | "branch-auto-merge-wait" | "branch-direct-merge" | "step" | "permission" | "escalation" | "needs_input" | "preflight" | "rafi-plan" | "ticket-populate" | "uninstall-proposal" | "qa" | "qa-fix" | "qa-protected-files-changed" | "qa-evidence" | "agent-status" | "error";
|
|
5
5
|
[key: string]: unknown;
|
|
6
6
|
}
|
|
7
7
|
/** Append-only JSONL logger. One line per record, plus an echo to the console. */
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { OperationRecord } from "./workflowDb.js";
|
|
2
|
+
export type ReconciliationResult = {
|
|
3
|
+
outcome: "confirmed";
|
|
4
|
+
externalId?: string;
|
|
5
|
+
detail: string;
|
|
6
|
+
} | {
|
|
7
|
+
outcome: "absent";
|
|
8
|
+
detail: string;
|
|
9
|
+
} | {
|
|
10
|
+
outcome: "uncertain";
|
|
11
|
+
detail: string;
|
|
12
|
+
inspectionCommands: string[];
|
|
13
|
+
};
|
|
14
|
+
export interface RemoteReviewInspector {
|
|
15
|
+
(intent: Record<string, unknown>): Promise<{
|
|
16
|
+
found: boolean;
|
|
17
|
+
url?: string;
|
|
18
|
+
head?: string;
|
|
19
|
+
base?: string;
|
|
20
|
+
uncertain?: string;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
/** Prove a side effect before recovery decides whether the stable operation may retry. */
|
|
24
|
+
export declare function reconcileOperation(projectDir: string, operation: OperationRecord, inspectReview?: RemoteReviewInspector): Promise<ReconciliationResult>;
|
|
25
|
+
export declare function recoveryDecision(result: ReconciliationResult): "continue" | "retry" | "pause";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
/** Prove a side effect before recovery decides whether the stable operation may retry. */
|
|
3
|
+
export async function reconcileOperation(projectDir, operation, inspectReview) {
|
|
4
|
+
const intent = operation.intent;
|
|
5
|
+
if (operation.status === "confirmed")
|
|
6
|
+
return { outcome: "confirmed", externalId: operation.externalId, detail: "operation already has a confirmed receipt" };
|
|
7
|
+
if (operation.kind === "commit") {
|
|
8
|
+
const sha = String(operation.result?.sha ?? operation.externalId ?? "");
|
|
9
|
+
if (!sha)
|
|
10
|
+
return { outcome: "absent", detail: "no commit receipt or SHA exists" };
|
|
11
|
+
const exists = spawnSync("git", ["-C", projectDir, "cat-file", "-e", `${sha}^{commit}`], { encoding: "utf8" });
|
|
12
|
+
return exists.status === 0 ? { outcome: "confirmed", externalId: sha, detail: `commit ${sha} exists` } : { outcome: "absent", detail: `commit ${sha} is absent` };
|
|
13
|
+
}
|
|
14
|
+
if (operation.kind === "push") {
|
|
15
|
+
const branch = String(intent.branch ?? "");
|
|
16
|
+
const expected = String(intent.sha ?? "");
|
|
17
|
+
const check = spawnSync("git", ["-C", projectDir, "ls-remote", "--heads", "origin", `refs/heads/${branch}`], { encoding: "utf8" });
|
|
18
|
+
if (check.status !== 0)
|
|
19
|
+
return { outcome: "uncertain", detail: check.stderr.trim() || "remote branch lookup failed", inspectionCommands: [`git -C ${quote(projectDir)} ls-remote --heads origin refs/heads/${branch}`] };
|
|
20
|
+
const actual = check.stdout.trim().split(/\s+/)[0] ?? "";
|
|
21
|
+
if (!actual)
|
|
22
|
+
return { outcome: "absent", detail: `remote branch ${branch} is absent` };
|
|
23
|
+
if (expected && actual !== expected)
|
|
24
|
+
return { outcome: "uncertain", detail: `remote branch ${branch} is ${actual}, expected ${expected}`, inspectionCommands: [`git -C ${quote(projectDir)} fetch origin ${quote(branch)}`, `git -C ${quote(projectDir)} log --oneline --decorate -5 origin/${quote(branch)}`] };
|
|
25
|
+
return { outcome: "confirmed", externalId: branch, detail: `remote branch ${branch} points at ${actual}` };
|
|
26
|
+
}
|
|
27
|
+
if (operation.kind === "pr-create" || operation.kind === "mr-create") {
|
|
28
|
+
if (!inspectReview)
|
|
29
|
+
return { outcome: "uncertain", detail: "no authenticated review inspector is available", inspectionCommands: [operation.kind === "pr-create" ? "gh pr list --state all --json url,headRefName,baseRefName" : "glab mr list --all"] };
|
|
30
|
+
const result = await inspectReview(intent);
|
|
31
|
+
if (result.uncertain)
|
|
32
|
+
return { outcome: "uncertain", detail: result.uncertain, inspectionCommands: [operation.kind === "pr-create" ? "gh pr list --state all --json url,headRefName,baseRefName" : "glab mr list --all"] };
|
|
33
|
+
if (!result.found)
|
|
34
|
+
return { outcome: "absent", detail: "matching review is absent" };
|
|
35
|
+
if (result.head !== intent.head || result.base !== intent.base)
|
|
36
|
+
return { outcome: "uncertain", detail: `review head/base does not match intended ${intent.head} -> ${intent.base}`, inspectionCommands: [operation.kind === "pr-create" ? `gh pr view ${quote(result.url ?? "")}` : `glab mr view ${quote(result.url ?? "")}`] };
|
|
37
|
+
return { outcome: "confirmed", externalId: result.url, detail: "matching review exists with the intended head and base" };
|
|
38
|
+
}
|
|
39
|
+
if (operation.kind === "ticket-complete" || operation.kind === "tracker-update")
|
|
40
|
+
return { outcome: "uncertain", detail: "tracker reconciliation requires the tracker state adapter", inspectionCommands: ["rafi tickets validate", "rafi tickets queue"] };
|
|
41
|
+
return { outcome: "uncertain", detail: `no reconciler registered for ${operation.kind}`, inspectionCommands: ["rafi status"] };
|
|
42
|
+
}
|
|
43
|
+
export function recoveryDecision(result) {
|
|
44
|
+
return result.outcome === "confirmed" ? "continue" : result.outcome === "absent" ? "retry" : "pause";
|
|
45
|
+
}
|
|
46
|
+
function quote(value) { return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replace(/'/g, `'"'"'`)}'`; }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ConfigurableAgentRole, WorkflowIssue, WorkflowIssueCode } from "rafi-spec";
|
|
2
|
+
import type { StepStatus } from "./foreman.js";
|
|
3
|
+
export interface IssueContext {
|
|
4
|
+
role?: ConfigurableAgentRole;
|
|
5
|
+
phase: string;
|
|
6
|
+
step?: number;
|
|
7
|
+
ticket?: string;
|
|
8
|
+
stack?: string;
|
|
9
|
+
qaCycle?: number;
|
|
10
|
+
provider?: "claude" | "codex";
|
|
11
|
+
model?: string;
|
|
12
|
+
now?: Date;
|
|
13
|
+
}
|
|
14
|
+
export declare function protocolIssue(status: StepStatus, context: IssueContext): WorkflowIssue;
|
|
15
|
+
export declare function issue(code: WorkflowIssueCode, context: IssueContext, detail: string, humanRequired: boolean, recoverable: boolean, suggestedAction: string): WorkflowIssue;
|
|
16
|
+
export declare function formatWorkflowIssue(value: WorkflowIssue): string;
|
package/dist/outcomes.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function protocolIssue(status, context) {
|
|
2
|
+
const detail = status.error ?? "role did not emit a final marker";
|
|
3
|
+
let code = "role_marker_missing";
|
|
4
|
+
if (/multiple|duplicate/i.test(detail))
|
|
5
|
+
code = "role_marker_duplicated";
|
|
6
|
+
else if (/not the final/i.test(detail))
|
|
7
|
+
code = "role_marker_non_final";
|
|
8
|
+
else if (/malformed|unterminated|field/i.test(detail))
|
|
9
|
+
code = "role_marker_malformed";
|
|
10
|
+
else if (/exhausted/i.test(detail))
|
|
11
|
+
code = "role_protocol_exhausted";
|
|
12
|
+
else if (status.kind !== "unknown")
|
|
13
|
+
code = "role_marker_invalid";
|
|
14
|
+
return issue(code, context, detail, true, true, "Inspect the saved output and resume the exact checkpoint after correcting the protocol response.");
|
|
15
|
+
}
|
|
16
|
+
export function issue(code, context, detail, humanRequired, recoverable, suggestedAction) {
|
|
17
|
+
return {
|
|
18
|
+
code, role: context.role, phase: context.phase, step: context.step, ticket: context.ticket, stack: context.stack,
|
|
19
|
+
qa_cycle: context.qaCycle, provider: context.provider, model: context.model, detail,
|
|
20
|
+
human_required: humanRequired, recoverable, suggested_action: suggestedAction,
|
|
21
|
+
occurred_at: (context.now ?? new Date()).toISOString(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function formatWorkflowIssue(value) {
|
|
25
|
+
const scope = [value.role, value.ticket && `ticket=${value.ticket}`, value.stack && `stack=${value.stack}`, value.step && `step=${value.step}`].filter(Boolean).join(" ");
|
|
26
|
+
return `${value.code}${scope ? ` (${scope})` : ""}: ${value.detail}\nSuggested action: ${value.suggested_action}`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { SessionStrategy } from "rafi-spec";
|
|
2
|
+
import type { BuilderAdapter } from "./adapters/types.js";
|
|
3
|
+
import type { TicketDef } from "./tickets/ticketSchema.js";
|
|
4
|
+
export interface QaStreamState {
|
|
5
|
+
sessionId?: string;
|
|
6
|
+
reviews: number;
|
|
7
|
+
modificationViolations: number;
|
|
8
|
+
}
|
|
9
|
+
export interface QaNonconvergenceContext {
|
|
10
|
+
ticket: TicketDef;
|
|
11
|
+
history: Array<{
|
|
12
|
+
cycle: number;
|
|
13
|
+
outcome: string;
|
|
14
|
+
detail: string;
|
|
15
|
+
}>;
|
|
16
|
+
builderWorktree: string;
|
|
17
|
+
}
|
|
18
|
+
export type QaNonconvergenceDecision = {
|
|
19
|
+
action: "retry" | "pause" | "waive" | "remediate";
|
|
20
|
+
remediation?: string;
|
|
21
|
+
};
|
|
22
|
+
export interface IsolatedQaOptions {
|
|
23
|
+
ticket: TicketDef;
|
|
24
|
+
builderWorktree: string;
|
|
25
|
+
builderSummary: string;
|
|
26
|
+
qaStrategy: SessionStrategy;
|
|
27
|
+
state: QaStreamState;
|
|
28
|
+
createQa: (cwd: string, sessionId?: string) => Promise<BuilderAdapter>;
|
|
29
|
+
fix: (issues: string) => Promise<{
|
|
30
|
+
ok: boolean;
|
|
31
|
+
detail?: string;
|
|
32
|
+
}>;
|
|
33
|
+
maxCycles: number;
|
|
34
|
+
evidence?: (entry: {
|
|
35
|
+
cycle: number;
|
|
36
|
+
outcome: string;
|
|
37
|
+
detail: string;
|
|
38
|
+
qaDiff?: string[];
|
|
39
|
+
}) => void;
|
|
40
|
+
onNonconvergence?: (context: QaNonconvergenceContext) => Promise<QaNonconvergenceDecision>;
|
|
41
|
+
}
|
|
42
|
+
export interface IsolatedQaResult {
|
|
43
|
+
outcome: "passed" | "blocked" | "needs-human" | "nonconverged" | "waived";
|
|
44
|
+
detail?: string;
|
|
45
|
+
summary?: string;
|
|
46
|
+
}
|
|
47
|
+
export declare function runIsolatedQa(opts: IsolatedQaOptions): Promise<IsolatedQaResult>;
|
|
48
|
+
export declare function compactWithRetry(adapter: BuilderAdapter): Promise<{
|
|
49
|
+
ok: boolean;
|
|
50
|
+
error?: string;
|
|
51
|
+
}>;
|
|
52
|
+
export declare function buildDurableQaFixHandoff(ticket: TicketDef, issues: string, worktree: string, priorResult: string, diffDigest: string): string;
|