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/start.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { resolve, join, relative } from "node:path";
|
|
3
3
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
4
5
|
import { select, text, isCancel } from "@clack/prompts";
|
|
5
6
|
import { loadConfig } from "../config.js";
|
|
6
7
|
import { Log } from "../log.js";
|
|
@@ -21,11 +22,14 @@ import { currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } fr
|
|
|
21
22
|
import { formatGitHubFailure, preflightGh } from "../branch/github.js";
|
|
22
23
|
import { preflightGlab } from "../branch/gitlab.js";
|
|
23
24
|
import { readDeliveryUnitSession, runBranchPlan } from "../branch/runner.js";
|
|
24
|
-
import { checkpointBuildRun, completeBuildRun, createBuildRun, heartbeatBuildRun, persistBuildSession, releaseBuildLease } from "../buildRuns.js";
|
|
25
|
+
import { checkpointBuildRun, completeBuildRun, createBuildRun, heartbeatBuildRun, persistBuildSession, readBuildRuns, releaseBuildLease, resumeBuildRun } from "../buildRuns.js";
|
|
25
26
|
import { parse as parseYaml } from "yaml";
|
|
26
27
|
import { findResumableBranchSessions, formatBranchContinueCommand, formatBranchSummaryFollowupCommands, } from "../branch/resume.js";
|
|
27
28
|
import { detectGitProvider, loadTicketSetupConfig } from "../tickets/setupConfig.js";
|
|
28
|
-
import { loadDeliveryConfig, selectDeliveryUnitForRun } from "../tickets/delivery.js";
|
|
29
|
+
import { loadDeliveryConfig, selectDeliveryUnitForRun, selectStacksForRun, updateStackDeliveryState } from "../tickets/delivery.js";
|
|
30
|
+
import { AgentStatusReporter } from "../statusReporter.js";
|
|
31
|
+
import { WorkflowDb } from "../workflowDb.js";
|
|
32
|
+
import { makeLogPath as makeRoleLogPath, readOnlyPermissionConfig, runRoleInstruction } from "../agentRun.js";
|
|
29
33
|
function fail(message) {
|
|
30
34
|
console.error(`foreman: ${message}`);
|
|
31
35
|
process.exit(1);
|
|
@@ -247,10 +251,12 @@ export function buildStartCommand() {
|
|
|
247
251
|
return new Command("start")
|
|
248
252
|
.description("Enlist a builder and drive it through a batch of N steps.")
|
|
249
253
|
.argument("<project>", "path to the project directory the builder works in")
|
|
250
|
-
.
|
|
254
|
+
.option("-s, --steps <n>", "number of tickets to drive; may stop within a stack")
|
|
255
|
+
.option("--stacks <n>", "number of complete eligible delivery stacks to build")
|
|
251
256
|
.option("-a, --agent <agent>", "builder agent (claude | codex)")
|
|
252
257
|
.option("-m, --model <model>", "override the builder's model")
|
|
253
258
|
.option("-r, --resume <sessionId>", "resume a prior builder session")
|
|
259
|
+
.option("--recover-run <id>", "continue an existing master recovery run ID")
|
|
254
260
|
.option("--continue", "resume the most recent logged session for this project")
|
|
255
261
|
.option("-t, --tickets <path>", "path to ticket file (.md, .txt, .yaml, …) — passed to the builder as context")
|
|
256
262
|
.option("-y, --yes", "skip pre-flight confirmation prompt")
|
|
@@ -269,15 +275,36 @@ export function buildStartCommand() {
|
|
|
269
275
|
.option("--auto-merge-timeout-minutes <n>", "auto-merge dependency wait timeout in minutes (blank means no timeout)")
|
|
270
276
|
.option("--base <ref>", "base ref for root ticket branches (default: current branch or HEAD)")
|
|
271
277
|
.option("--branch-prefix <prefix>", "branch name prefix for ticket branches", "rafi")
|
|
272
|
-
.option("--max-branch-depth <n>", "maximum selected branch stack depth", "
|
|
278
|
+
.option("--max-branch-depth <n>", "maximum selected branch stack depth", "5")
|
|
273
279
|
.option("--pr-ready", "create ready-for-review PRs instead of draft PRs")
|
|
274
280
|
.option("--keep-worktrees", "keep successful ticket worktrees for inspection")
|
|
275
281
|
.option("--ticket <id>", "ticket id to continue in branch mode; repeat for multiple tickets", collectTicket, [])
|
|
276
282
|
.option("--skip-delivery-unit <id>", "skip one unfinished delivery unit for this run", collectTicket, [])
|
|
277
283
|
.action(async (project, opts, command) => {
|
|
278
|
-
|
|
284
|
+
if (opts.steps !== undefined && opts.stacks !== undefined)
|
|
285
|
+
fail("--steps and --stacks are mutually exclusive");
|
|
286
|
+
if (opts.steps === undefined && opts.stacks === undefined) {
|
|
287
|
+
if (opts.yes || !process.stdin.isTTY || !process.stdout.isTTY)
|
|
288
|
+
fail("noninteractive/--yes runs require exactly one of --steps or --stacks");
|
|
289
|
+
const mode = await select({ message: "Build tickets or complete delivery stacks?", options: [
|
|
290
|
+
{ value: "steps", label: "Tickets (Recommended)" }, { value: "stacks", label: "Complete stacks" },
|
|
291
|
+
] });
|
|
292
|
+
if (isCancel(mode))
|
|
293
|
+
process.exit(0);
|
|
294
|
+
const count = await text({ message: mode === "steps" ? "How many tickets?" : "How many complete stacks?", validate: (value) => Number.isInteger(Number(value)) && Number(value) > 0 ? undefined : "Enter a positive integer" });
|
|
295
|
+
if (isCancel(count))
|
|
296
|
+
process.exit(0);
|
|
297
|
+
opts[mode] = String(count);
|
|
298
|
+
}
|
|
299
|
+
let steps = Number.parseInt(opts.steps ?? "0", 10);
|
|
300
|
+
const stackCount = opts.stacks === undefined ? undefined : Number.parseInt(opts.stacks, 10);
|
|
301
|
+
if (stackCount !== undefined && (!Number.isInteger(stackCount) || stackCount < 1))
|
|
302
|
+
fail("--stacks must be a positive integer");
|
|
303
|
+
if (stackCount !== undefined)
|
|
304
|
+
steps = 0;
|
|
279
305
|
if (!Number.isInteger(steps) || steps < 1) {
|
|
280
|
-
|
|
306
|
+
if (stackCount === undefined)
|
|
307
|
+
fail("--steps must be a positive integer");
|
|
281
308
|
}
|
|
282
309
|
const VALID_EFFORT = ["low", "medium", "high", "xhigh"];
|
|
283
310
|
if (opts.effort && !VALID_EFFORT.includes(opts.effort)) {
|
|
@@ -286,17 +313,40 @@ export function buildStartCommand() {
|
|
|
286
313
|
const cwd = resolve(project);
|
|
287
314
|
if (!existsSync(cwd))
|
|
288
315
|
fail(`project directory not found: ${cwd}`);
|
|
316
|
+
const recoveryRecord = opts.recoverRun ? readBuildRuns(cwd).find((run) => run.runId === opts.recoverRun) : undefined;
|
|
317
|
+
if (opts.recoverRun && !recoveryRecord)
|
|
318
|
+
fail(`recoverable build run not found: ${opts.recoverRun}`);
|
|
289
319
|
const roleDefaults = readAgentDefaults(cwd);
|
|
290
320
|
let deliveryRun;
|
|
321
|
+
let deliveryConfig;
|
|
322
|
+
let selectedStacks = [];
|
|
323
|
+
let selectedStackTickets;
|
|
291
324
|
if (isTicketsInitialized(cwd)) {
|
|
292
325
|
const delivery = loadDeliveryConfig(cwd);
|
|
326
|
+
deliveryConfig = delivery;
|
|
293
327
|
if (delivery) {
|
|
294
328
|
const earlyConfig = loadTicketsConfig(cwd);
|
|
295
329
|
const earlyPaths = resolveTicketPaths(earlyConfig, cwd);
|
|
296
330
|
const earlyDb = new StateDb(earlyPaths.stateDb);
|
|
297
331
|
const earlyStates = earlyDb.getAllStates();
|
|
298
332
|
earlyDb.close();
|
|
299
|
-
|
|
333
|
+
if (stackCount !== undefined) {
|
|
334
|
+
const earlyTickets = loadTickets(earlyPaths.tickets);
|
|
335
|
+
const selection = selectStacksForRun(delivery, earlyTickets, earlyStates, stackCount);
|
|
336
|
+
if (selection.error)
|
|
337
|
+
fail(selection.error);
|
|
338
|
+
selectedStacks = selection.stacks;
|
|
339
|
+
selectedStackTickets = selection.tickets;
|
|
340
|
+
steps = selection.tickets.length;
|
|
341
|
+
opts.branchPerTicket = true;
|
|
342
|
+
opts.completion = "pr";
|
|
343
|
+
const providers = new Set(selection.stacks.flatMap((stack) => stack.units.map((id) => delivery.units.find((unit) => unit.id === id)?.provider)).filter((provider) => provider === "github" || provider === "gitlab"));
|
|
344
|
+
if (providers.size !== 1)
|
|
345
|
+
fail("selected stacks must use one explicit GitHub or GitLab provider per run");
|
|
346
|
+
opts.provider = [...providers][0];
|
|
347
|
+
}
|
|
348
|
+
else
|
|
349
|
+
deliveryRun = selectDeliveryUnitForRun(delivery, earlyStates, opts.skipDeliveryUnit ?? []);
|
|
300
350
|
if (deliveryRun?.state === "resume" && !opts.yes && process.stdin.isTTY && process.stdout.isTTY) {
|
|
301
351
|
const answer = await select({ message: `Resume unfinished delivery unit ${deliveryRun.unit.id} (${deliveryRun.remaining.length} remaining)?`, options: [
|
|
302
352
|
{ value: "resume", label: "Resume (Recommended)" }, { value: "skip", label: "Skip for this run" },
|
|
@@ -308,6 +358,8 @@ export function buildStartCommand() {
|
|
|
308
358
|
}
|
|
309
359
|
}
|
|
310
360
|
}
|
|
361
|
+
if (stackCount !== undefined && selectedStacks.length !== stackCount)
|
|
362
|
+
fail("--stacks requires explicit eligible stacks in .tickets/delivery.yaml");
|
|
311
363
|
const branchDefaults = resolveStartBranchDefaults(cwd, opts, command, deliveryRun);
|
|
312
364
|
let agent;
|
|
313
365
|
try {
|
|
@@ -325,6 +377,7 @@ export function buildStartCommand() {
|
|
|
325
377
|
let qaExecutable;
|
|
326
378
|
const qaEffort = explicitEffort(roleDefaults.qa?.reasoning);
|
|
327
379
|
const qaFast = roleDefaults.qa?.fast ?? false;
|
|
380
|
+
const settingsRevision = roleDefaults.revision ?? 0;
|
|
328
381
|
const configuredTrackerPath = isTicketsInitialized(cwd)
|
|
329
382
|
? loadTicketsConfig(cwd).paths.progressDoc
|
|
330
383
|
: undefined;
|
|
@@ -368,7 +421,7 @@ export function buildStartCommand() {
|
|
|
368
421
|
if (opts.resume && continueTickets.length > 1) {
|
|
369
422
|
fail("--resume <sessionId> in branch mode supports exactly one --ticket; use --continue for multiple tickets");
|
|
370
423
|
}
|
|
371
|
-
if ((opts.resume || opts.continue) && continueTickets.length === 0) {
|
|
424
|
+
if ((opts.resume || opts.continue) && continueTickets.length === 0 && !recoveryRecord) {
|
|
372
425
|
fail(branchContinueTicketHelp(cwd, opts.resume ? "--resume" : "--continue"));
|
|
373
426
|
}
|
|
374
427
|
if (continueTickets.length > 0 && !(opts.resume || opts.continue)) {
|
|
@@ -472,6 +525,7 @@ export function buildStartCommand() {
|
|
|
472
525
|
},
|
|
473
526
|
});
|
|
474
527
|
};
|
|
528
|
+
const qaNonconvergence = createQaNonconvergenceHandler(cwd, Boolean(opts.yes), roleDefaults.planner);
|
|
475
529
|
if (branchMode) {
|
|
476
530
|
const ticketsConfig = loadTicketsConfig(cwd);
|
|
477
531
|
const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
|
|
@@ -548,7 +602,7 @@ export function buildStartCommand() {
|
|
|
548
602
|
branchPrefix: opts.branchPrefix,
|
|
549
603
|
maxBranchDepth,
|
|
550
604
|
rootBaseBranches: branchDefaults.rootBaseBranches,
|
|
551
|
-
ticketIds: deliveryRun?.remaining.slice(0, steps),
|
|
605
|
+
ticketIds: selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
|
|
552
606
|
});
|
|
553
607
|
let auditBuilder;
|
|
554
608
|
let auditViewer;
|
|
@@ -566,7 +620,7 @@ export function buildStartCommand() {
|
|
|
566
620
|
maxBranchDepth,
|
|
567
621
|
auditDependencies,
|
|
568
622
|
rootBaseBranches: branchDefaults.rootBaseBranches,
|
|
569
|
-
ticketIds: deliveryRun?.remaining.slice(0, steps),
|
|
623
|
+
ticketIds: selectedStackTickets ?? deliveryRun?.remaining.slice(0, steps) ?? recoveryRecord?.tickets,
|
|
570
624
|
});
|
|
571
625
|
log.write("branch-plan", {
|
|
572
626
|
baseRef: plan.baseRef,
|
|
@@ -588,6 +642,14 @@ export function buildStartCommand() {
|
|
|
588
642
|
resumeSessionByTicket.set(first.ticket.id, { worktreePath: saved.worktreePath, sessionId: saved.sessionId });
|
|
589
643
|
}
|
|
590
644
|
}
|
|
645
|
+
if (selectedStacks.length && deliveryConfig) {
|
|
646
|
+
plan = applyExplicitStackTopology(plan, selectedStacks, deliveryConfig, opts.branchPrefix);
|
|
647
|
+
}
|
|
648
|
+
if (recoveryRecord && opts.resume && plan.nodes[0]) {
|
|
649
|
+
const prior = findResumableBranchSessions(join(cwd, ".foreman")).find((session) => session.ticket === (recoveryRecord.currentTicket ?? plan.nodes[0].ticket.id));
|
|
650
|
+
if (prior)
|
|
651
|
+
resumeSessionByTicket.set(plan.nodes[0].ticket.id, { worktreePath: prior.worktreePath, sessionId: opts.resume });
|
|
652
|
+
}
|
|
591
653
|
}
|
|
592
654
|
console.log(`foreman: ${continueTickets.length > 0 ? "resuming branch-per-ticket mode" : "branch-per-ticket mode"} for ${plan.nodes.length} ticket(s)`);
|
|
593
655
|
console.log(`foreman: project ${cwd}`);
|
|
@@ -620,9 +682,24 @@ export function buildStartCommand() {
|
|
|
620
682
|
process.exit(0);
|
|
621
683
|
}
|
|
622
684
|
}
|
|
685
|
+
const capturedBranchBuilder = {
|
|
686
|
+
role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
|
|
687
|
+
make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
|
|
688
|
+
session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
|
|
689
|
+
};
|
|
690
|
+
const capturedBranchQa = {
|
|
691
|
+
role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default",
|
|
692
|
+
reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", settings_revision: settingsRevision,
|
|
693
|
+
};
|
|
694
|
+
let masterRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBranchBuilder, qa: capturedBranchQa, builderSessionId: opts.resume ? String(opts.resume) : null }) : createBuildRun({
|
|
695
|
+
tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
|
|
696
|
+
repositoryRoot: cwd, branchMode: "per-ticket", builder: capturedBranchBuilder, qa: capturedBranchQa,
|
|
697
|
+
});
|
|
698
|
+
const branchHeartbeat = setInterval(() => { masterRun = heartbeatBuildRun(cwd, masterRun); }, 10_000);
|
|
699
|
+
branchHeartbeat.unref();
|
|
623
700
|
const summaries = await runBranchPlan({
|
|
624
701
|
projectDir: cwd,
|
|
625
|
-
runId:
|
|
702
|
+
runId: masterRun.runId,
|
|
626
703
|
plan,
|
|
627
704
|
log,
|
|
628
705
|
agent,
|
|
@@ -635,7 +712,7 @@ export function buildStartCommand() {
|
|
|
635
712
|
completionMode: branchDefaults.completionMode,
|
|
636
713
|
reviewProvider: branchDefaults.reviewProvider,
|
|
637
714
|
prReady: branchDefaults.prReady,
|
|
638
|
-
keepWorktrees: Boolean(opts.keepWorktrees),
|
|
715
|
+
keepWorktrees: Boolean(opts.keepWorktrees) || selectedStacks.length > 0,
|
|
639
716
|
cleanupBranches: branchDefaults.cleanupBranches,
|
|
640
717
|
autoMergeWait: branchDefaults.autoMergeWait,
|
|
641
718
|
autoMergeTimeoutMinutes: branchDefaults.autoMergeTimeoutMinutes,
|
|
@@ -647,8 +724,34 @@ export function buildStartCommand() {
|
|
|
647
724
|
},
|
|
648
725
|
resumeSessions: resumeSessionByTicket,
|
|
649
726
|
createBuilder: (builderCwd, sessionId) => createBuilder(builderCwd, sessionId),
|
|
727
|
+
createQa: (qaCwd, sessionId) => createQa(qaCwd, sessionId),
|
|
728
|
+
builderSessionStrategy: roleDefaults.builder?.session_strategy ?? "compact",
|
|
729
|
+
qaSessionStrategy: roleDefaults.qa?.session_strategy ?? "compact",
|
|
650
730
|
observeBuilder: (builder) => printEvents(builder.events()),
|
|
731
|
+
qaNonconvergence,
|
|
651
732
|
});
|
|
733
|
+
clearInterval(branchHeartbeat);
|
|
734
|
+
if (selectedStacks.length) {
|
|
735
|
+
let allComplete = true;
|
|
736
|
+
for (const stack of selectedStacks) {
|
|
737
|
+
const stackTickets = stack.units.flatMap((id) => deliveryConfig?.units.find((unit) => unit.id === id)?.tickets ?? []);
|
|
738
|
+
const rows = summaries.filter((row) => stackTickets.includes(row.ticket));
|
|
739
|
+
const complete = rows.length > 0 && rows.every((row) => row.buildStatus === "done" && (!row.pr || ["created", "existing"].includes(row.pr.status)));
|
|
740
|
+
allComplete &&= complete;
|
|
741
|
+
const successful = rows.filter((row) => row.buildStatus === "done");
|
|
742
|
+
updateStackDeliveryState(cwd, [stack.id], complete ? "awaiting_review" : "partial", {
|
|
743
|
+
review_links: [...(stack.review_links ?? []), ...successful.flatMap((row) => row.pr?.url ? [row.pr.url] : [])],
|
|
744
|
+
published_branches: { ...(stack.published_branches ?? {}), ...Object.fromEntries(successful.map((row) => [row.ticket, row.branch])) },
|
|
745
|
+
completed_prefix: [...(stack.completed_prefix ?? []), ...successful.map((row) => row.ticket)],
|
|
746
|
+
next_ticket: complete ? undefined : stackTickets.find((ticket) => !successful.some((row) => row.ticket === ticket)),
|
|
747
|
+
remote_checked_at: stack.remote_checked_at, remote_stale: true,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
console.log(allComplete ? "foreman: stack publication complete; delivery is awaiting_review (no merges were attempted)" : "foreman: partial stack prefix published; resume point is preserved");
|
|
751
|
+
}
|
|
752
|
+
masterRun = summaries.every((row) => row.buildStatus === "done")
|
|
753
|
+
? completeBuildRun(cwd, masterRun)
|
|
754
|
+
: releaseBuildLease(cwd, checkpointBuildRun(cwd, masterRun, "branch-run-interrupted", { status: "recoverable" }), "recoverable");
|
|
652
755
|
console.log("foreman: branch run summary");
|
|
653
756
|
console.log("ticket\tbranch\tbase\tstatus\tcommit\tpush\tcompletion");
|
|
654
757
|
for (const row of summaries) {
|
|
@@ -691,9 +794,10 @@ export function buildStartCommand() {
|
|
|
691
794
|
const capturedBuilder = {
|
|
692
795
|
role: "builder", source: opts.agent || opts.model || opts.effort || opts.fast ? "cli" : roleDefaults.builder ? "project" : "provider",
|
|
693
796
|
make: agent, model: model ?? "default", reasoning: builderEffort ?? "default", fast: Boolean(builderFast),
|
|
797
|
+
session_strategy: roleDefaults.builder?.session_strategy ?? "compact", settings_revision: settingsRevision,
|
|
694
798
|
};
|
|
695
|
-
const capturedQa = { role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default", reasoning: qaEffort ?? "default", fast: qaFast };
|
|
696
|
-
let buildRun = createBuildRun({
|
|
799
|
+
const capturedQa = { role: "qa", source: roleDefaults.qa ? "project" : "provider", make: qaAgent, model: qaModel ?? "default", reasoning: qaEffort ?? "default", fast: qaFast, session_strategy: roleDefaults.qa?.session_strategy ?? "compact", settings_revision: settingsRevision };
|
|
800
|
+
let buildRun = recoveryRecord ? resumeBuildRun(cwd, recoveryRecord.runId, { builder: capturedBuilder, qa: capturedQa, builderSessionId: opts.resume ? String(opts.resume) : null }) : createBuildRun({
|
|
697
801
|
tickets: [], repositoryRoot: cwd, branchMode: "current", builder: capturedBuilder, qa: capturedQa,
|
|
698
802
|
});
|
|
699
803
|
const heartbeat = setInterval(() => { buildRun = heartbeatBuildRun(cwd, buildRun); }, 10_000);
|
|
@@ -707,8 +811,23 @@ export function buildStartCommand() {
|
|
|
707
811
|
process.once("SIGINT", checkpointSignal);
|
|
708
812
|
process.once("SIGTERM", checkpointSignal);
|
|
709
813
|
const builder = await createBuilder(cwd, resumeSessionId);
|
|
710
|
-
const
|
|
711
|
-
const
|
|
814
|
+
const foreman = new Foreman(builder, log, config.notifications.enabled, qaEnabled, 3, cwd, undefined, qaEnabled ? createQa : undefined, capturedQa.session_strategy, createBuilder, capturedBuilder.session_strategy, qaNonconvergence);
|
|
815
|
+
const statusReporter = new AgentStatusReporter({
|
|
816
|
+
role: "builder", provider: capturedBuilder.make, model: capturedBuilder.model,
|
|
817
|
+
reasoning: capturedBuilder.reasoning, fast: capturedBuilder.fast, step: 1, total: steps,
|
|
818
|
+
phase: "builder work session", sessionTransition: resumeSessionId ? "resumed exact session" : "initial session", adapter: () => foreman.builderAdapter(),
|
|
819
|
+
}, (line, snapshot) => {
|
|
820
|
+
console.log(line);
|
|
821
|
+
log.write("agent-status", { ...snapshot });
|
|
822
|
+
const db = new WorkflowDb(cwd);
|
|
823
|
+
try {
|
|
824
|
+
db.recordTelemetry(buildRun.runId, snapshot);
|
|
825
|
+
}
|
|
826
|
+
finally {
|
|
827
|
+
db.close();
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
statusReporter.start();
|
|
712
831
|
const modifiers = [
|
|
713
832
|
model ? `model=${model}` : null,
|
|
714
833
|
builderEffort ? `effort=${builderEffort}` : null,
|
|
@@ -725,10 +844,10 @@ export function buildStartCommand() {
|
|
|
725
844
|
console.log("ai-foreman: asking builder to plan the next tickets or steps...\n");
|
|
726
845
|
buildRun = checkpointBuildRun(cwd, buildRun, "before-preflight");
|
|
727
846
|
await foreman.runPreflight(steps, ticketsContent);
|
|
728
|
-
if (
|
|
729
|
-
buildRun = persistBuildSession(cwd, buildRun, "builder",
|
|
730
|
-
if (
|
|
731
|
-
buildRun = persistBuildSession(cwd, buildRun, "qa",
|
|
847
|
+
if (foreman.builderSessionId())
|
|
848
|
+
buildRun = persistBuildSession(cwd, buildRun, "builder", foreman.builderSessionId());
|
|
849
|
+
if (foreman.qaSessionId())
|
|
850
|
+
buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionId());
|
|
732
851
|
buildRun = checkpointBuildRun(cwd, buildRun, "preflight-complete");
|
|
733
852
|
if (!opts.yes) {
|
|
734
853
|
while (true) {
|
|
@@ -743,7 +862,8 @@ export function buildStartCommand() {
|
|
|
743
862
|
});
|
|
744
863
|
if (isCancel(action) || action === "cancel") {
|
|
745
864
|
console.log("ai-foreman: cancelled");
|
|
746
|
-
|
|
865
|
+
statusReporter.stop();
|
|
866
|
+
await foreman.close();
|
|
747
867
|
await viewer;
|
|
748
868
|
process.exit(0);
|
|
749
869
|
}
|
|
@@ -757,7 +877,8 @@ export function buildStartCommand() {
|
|
|
757
877
|
});
|
|
758
878
|
if (isCancel(fb)) {
|
|
759
879
|
console.log("ai-foreman: cancelled");
|
|
760
|
-
|
|
880
|
+
statusReporter.stop();
|
|
881
|
+
await foreman.close();
|
|
761
882
|
await viewer;
|
|
762
883
|
process.exit(0);
|
|
763
884
|
}
|
|
@@ -766,12 +887,14 @@ export function buildStartCommand() {
|
|
|
766
887
|
}
|
|
767
888
|
}
|
|
768
889
|
const result = await foreman.runBatch(steps, trackerRelPath);
|
|
890
|
+
if (foreman.qaSessionId())
|
|
891
|
+
buildRun = persistBuildSession(cwd, buildRun, "qa", foreman.qaSessionId());
|
|
769
892
|
buildRun = result.outcome === "all-done" || result.outcome === "plan-complete"
|
|
770
893
|
? completeBuildRun(cwd, buildRun)
|
|
771
894
|
: releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "builder-or-qa-interrupted", { status: "recoverable" }), "recoverable");
|
|
772
895
|
clearInterval(heartbeat);
|
|
773
|
-
|
|
774
|
-
await
|
|
896
|
+
statusReporter.stop();
|
|
897
|
+
await foreman.close();
|
|
775
898
|
await viewer;
|
|
776
899
|
console.log(`\nforeman: ${result.completed}/${result.requested} step(s) completed`);
|
|
777
900
|
console.log(`foreman: outcome — ${result.outcome}`);
|
|
@@ -780,7 +903,7 @@ export function buildStartCommand() {
|
|
|
780
903
|
if (result.outcome !== "all-done" && result.outcome !== "plan-complete") {
|
|
781
904
|
const executable = command.parent?.name() === "rafi" ? "rafi" : "ai-foreman";
|
|
782
905
|
const remainingSteps = Math.max(1, result.requested - result.completed);
|
|
783
|
-
for (const line of formatResumeGuidance(executable, cwd, remainingSteps,
|
|
906
|
+
for (const line of formatResumeGuidance(executable, cwd, remainingSteps, foreman.builderSessionId())) {
|
|
784
907
|
console.log(line);
|
|
785
908
|
}
|
|
786
909
|
}
|
|
@@ -788,14 +911,99 @@ export function buildStartCommand() {
|
|
|
788
911
|
}
|
|
789
912
|
catch (err) {
|
|
790
913
|
clearInterval(heartbeat);
|
|
914
|
+
statusReporter.stop();
|
|
791
915
|
buildRun = releaseBuildLease(cwd, checkpointBuildRun(cwd, buildRun, "failed", { status: "failed", failure: { category: "unknown", summary: err instanceof Error ? err.message.slice(0, 1000) : String(err).slice(0, 1000), at: new Date().toISOString() } }), "failed");
|
|
792
|
-
await
|
|
793
|
-
await builder.close().catch(() => { });
|
|
916
|
+
await foreman.close().catch(() => { });
|
|
794
917
|
log.write("error", { message: String(err) });
|
|
795
918
|
fail(`run failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
796
919
|
}
|
|
797
920
|
});
|
|
798
921
|
}
|
|
922
|
+
function createQaNonconvergenceHandler(projectDir, noninteractive, planner) {
|
|
923
|
+
return async (context) => {
|
|
924
|
+
if (noninteractive || !process.stdin.isTTY || !process.stdout.isTTY)
|
|
925
|
+
return { action: "pause" };
|
|
926
|
+
while (true) {
|
|
927
|
+
const choice = await select({ message: `QA did not converge for ${context.ticket.id}. What next?`, options: [
|
|
928
|
+
{ value: "retry", label: "Retry another Builder fix" },
|
|
929
|
+
{ value: "pause", label: "Pause at this checkpoint" },
|
|
930
|
+
{ value: "waive", label: "Explicitly waive QA" },
|
|
931
|
+
{ value: "planner", label: "Discuss with read-only Planner" },
|
|
932
|
+
] });
|
|
933
|
+
if (isCancel(choice) || choice === "pause")
|
|
934
|
+
return { action: "pause" };
|
|
935
|
+
if (choice === "retry")
|
|
936
|
+
return { action: "retry" };
|
|
937
|
+
if (choice === "waive") {
|
|
938
|
+
const approved = await select({ message: "Waive QA and record validation_result=failed with all unresolved issues?", options: [
|
|
939
|
+
{ value: "no", label: "No (Recommended)" }, { value: "yes", label: "Yes, waive and continue" },
|
|
940
|
+
] });
|
|
941
|
+
if (approved === "yes") {
|
|
942
|
+
const db = new WorkflowDb(projectDir);
|
|
943
|
+
try {
|
|
944
|
+
const lease = db.currentLease();
|
|
945
|
+
if (lease) {
|
|
946
|
+
const detail = JSON.stringify({ decision: "waive", ticket: context.ticket.id, unresolved: context.history, at: new Date().toISOString() });
|
|
947
|
+
db.putEvidence("test", detail);
|
|
948
|
+
db.recordIssue(lease.runId, { code: "qa_nonconvergence", role: "qa", phase: "qa-waiver", ticket: context.ticket.id, detail, human_required: false, recoverable: false, suggested_action: "Review the explicit QA waiver before merging.", occurred_at: new Date().toISOString() });
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
finally {
|
|
952
|
+
db.close();
|
|
953
|
+
}
|
|
954
|
+
console.log(`foreman: QA WAIVER recorded for ${context.ticket.id}; validation_result will be failed`);
|
|
955
|
+
return { action: "waive" };
|
|
956
|
+
}
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
const discussion = await text({ message: "What should the Planner focus on?", defaultValue: "Explain the QA failures and propose the safest concrete remediation." });
|
|
960
|
+
if (isCancel(discussion))
|
|
961
|
+
continue;
|
|
962
|
+
const diff = execFileSync("git", ["-C", context.builderWorktree, "diff", "--binary", "HEAD"], { encoding: "utf8", maxBuffer: 128 * 1024 * 1024 });
|
|
963
|
+
let instruction = [
|
|
964
|
+
"You are the read-only Planner mediating QA nonconvergence. Do not edit files or run mutating tools.",
|
|
965
|
+
`Ticket: ${context.ticket.id} — ${context.ticket.title}`,
|
|
966
|
+
`Requirements: ${context.ticket.acceptance.join("; ")}`,
|
|
967
|
+
`Required tests: ${context.ticket.required_tests.join("; ")}`,
|
|
968
|
+
`User discussion: ${String(discussion)}`,
|
|
969
|
+
`Complete QA history:\n${JSON.stringify(context.history, null, 2)}`,
|
|
970
|
+
`Full Builder diff:\n${diff || "(no tracked diff)"}`,
|
|
971
|
+
"Return RAFI_QA_REMEDIATION_START, then one JSON object {\"summary\":\"...\",\"fix_instructions\":[\"...\"]}, then RAFI_QA_REMEDIATION_END.",
|
|
972
|
+
"End with STEP_STATUS: plan_complete | summary=\"QA remediation proposed\"",
|
|
973
|
+
].join("\n\n");
|
|
974
|
+
let resumeSessionId;
|
|
975
|
+
while (true) {
|
|
976
|
+
const run = await runRoleInstruction({
|
|
977
|
+
projectDir, role: "planner", instruction, label: "QA Planner remediation", agent: planner?.make,
|
|
978
|
+
model: explicitDefaultValue(planner?.model), effort: explicitEffort(planner?.reasoning), fast: planner?.fast,
|
|
979
|
+
resumeSessionId, permissionConfig: readOnlyPermissionConfig(), sandboxMode: "read-only",
|
|
980
|
+
logPath: makeRoleLogPath(projectDir, "qa-remediation"), logEvent: "rafi-plan",
|
|
981
|
+
});
|
|
982
|
+
if (run.turn.result.isError || run.turn.status.kind !== "plan_complete")
|
|
983
|
+
throw new Error(`Planner remediation failed: ${run.turn.status.error ?? run.turn.result.text.slice(0, 500)}`);
|
|
984
|
+
const match = /RAFI_QA_REMEDIATION_START\s*([\s\S]*?)\s*RAFI_QA_REMEDIATION_END/.exec(run.turn.result.text);
|
|
985
|
+
if (!match)
|
|
986
|
+
throw new Error("Planner remediation did not return the required structured proposal");
|
|
987
|
+
const proposal = JSON.parse(match[1].replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, ""));
|
|
988
|
+
if (!proposal.summary || !Array.isArray(proposal.fix_instructions) || !proposal.fix_instructions.every((item) => typeof item === "string"))
|
|
989
|
+
throw new Error("Planner remediation proposal is malformed");
|
|
990
|
+
console.log(`\nPlanner remediation: ${proposal.summary}\n${proposal.fix_instructions.map((item) => `- ${item}`).join("\n")}`);
|
|
991
|
+
const decision = await select({ message: "Use this remediation for a new Builder fix session?", options: [
|
|
992
|
+
{ value: "approve", label: "Approve (Recommended)" }, { value: "revise", label: "Discuss/revise" }, { value: "cancel", label: "Cancel and return to choices" },
|
|
993
|
+
] });
|
|
994
|
+
if (decision === "approve")
|
|
995
|
+
return { action: "remediate", remediation: [proposal.summary, ...proposal.fix_instructions].join("\n") };
|
|
996
|
+
if (isCancel(decision) || decision === "cancel")
|
|
997
|
+
break;
|
|
998
|
+
const feedback = await text({ message: "Planner revision feedback:" });
|
|
999
|
+
if (isCancel(feedback))
|
|
1000
|
+
break;
|
|
1001
|
+
resumeSessionId = run.sessionId;
|
|
1002
|
+
instruction = `Revise the complete QA remediation proposal using this feedback: ${String(feedback)}. Keep the exact structured envelope and final plan_complete marker.`;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
799
1007
|
function readAgentDefaults(projectDir) {
|
|
800
1008
|
const path = join(projectDir, "rafi-config.yaml");
|
|
801
1009
|
if (!existsSync(path))
|
|
@@ -804,7 +1012,7 @@ function readAgentDefaults(projectDir) {
|
|
|
804
1012
|
const parsed = parseYaml(readFileSync(path, "utf8"));
|
|
805
1013
|
if (parsed?.agent_defaults?.version !== 1)
|
|
806
1014
|
return {};
|
|
807
|
-
return { builder: parsed.agent_defaults.roles.builder, qa: parsed.agent_defaults.roles.qa };
|
|
1015
|
+
return { revision: parsed.agent_defaults.revision, builder: parsed.agent_defaults.roles.builder, qa: parsed.agent_defaults.roles.qa, planner: parsed.agent_defaults.roles.planner };
|
|
808
1016
|
}
|
|
809
1017
|
catch {
|
|
810
1018
|
return {};
|
|
@@ -813,6 +1021,53 @@ function readAgentDefaults(projectDir) {
|
|
|
813
1021
|
function explicitDefaultValue(value) {
|
|
814
1022
|
return value && value !== "default" ? value : undefined;
|
|
815
1023
|
}
|
|
1024
|
+
/** Make the executable branch plan exactly match each explicit root-to-tip stack. */
|
|
1025
|
+
export function applyExplicitStackTopology(plan, stacks, delivery, branchPrefix) {
|
|
1026
|
+
const nodeByTicket = new Map(plan.nodes.map((node) => [node.ticket.id, node]));
|
|
1027
|
+
const unitById = new Map(delivery.units.map((unit) => [unit.id, unit]));
|
|
1028
|
+
const ordered = [];
|
|
1029
|
+
for (const stack of stacks) {
|
|
1030
|
+
let predecessorBranch = plan.baseRef;
|
|
1031
|
+
let predecessorActiveTicket;
|
|
1032
|
+
let missingPredecessorIdentity;
|
|
1033
|
+
let depth = 0;
|
|
1034
|
+
for (const unitId of stack.units) {
|
|
1035
|
+
const unit = unitById.get(unitId);
|
|
1036
|
+
if (!unit)
|
|
1037
|
+
throw new Error(`stack ${stack.id} references missing delivery unit ${unitId}`);
|
|
1038
|
+
const sharedBranch = unit.branch_mode === "shared" ? `${branchPrefix.replace(/^\/+|\/+$/g, "") || "rafi"}/${slug(unitId)}` : undefined;
|
|
1039
|
+
const unitBaseBranch = predecessorBranch;
|
|
1040
|
+
for (let ticketIndex = 0; ticketIndex < unit.tickets.length; ticketIndex++) {
|
|
1041
|
+
const ticketId = unit.tickets[ticketIndex];
|
|
1042
|
+
depth += unit.branch_mode === "shared" && ticketIndex > 0 ? 0 : 1;
|
|
1043
|
+
const node = nodeByTicket.get(ticketId);
|
|
1044
|
+
const recorded = stack.published_branches?.[ticketId];
|
|
1045
|
+
if (!node) {
|
|
1046
|
+
if (recorded || sharedBranch)
|
|
1047
|
+
predecessorBranch = recorded ?? sharedBranch;
|
|
1048
|
+
else
|
|
1049
|
+
missingPredecessorIdentity = ticketId;
|
|
1050
|
+
predecessorActiveTicket = undefined;
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
if (missingPredecessorIdentity)
|
|
1054
|
+
throw new Error(`partial stack ${stack.id} is missing the saved branch identity for completed predecessor ${missingPredecessorIdentity}`);
|
|
1055
|
+
const branch = sharedBranch ?? node.branch;
|
|
1056
|
+
ordered.push({
|
|
1057
|
+
...node, branch, baseBranch: sharedBranch ? unitBaseBranch : predecessorBranch,
|
|
1058
|
+
dependencies: predecessorActiveTicket ? [predecessorActiveTicket] : [], depth,
|
|
1059
|
+
...(unit.branch_mode === "shared" ? { deliveryUnitId: unit.id, deliveryUnitFinal: ticketIndex === unit.tickets.length - 1 } : {}),
|
|
1060
|
+
});
|
|
1061
|
+
predecessorBranch = branch;
|
|
1062
|
+
predecessorActiveTicket = ticketId;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
if (ordered.length !== plan.nodes.length)
|
|
1067
|
+
throw new Error("explicit stack topology did not assign every selected ticket exactly once");
|
|
1068
|
+
return { ...plan, nodes: ordered };
|
|
1069
|
+
}
|
|
1070
|
+
function slug(value) { return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "group"; }
|
|
816
1071
|
function explicitEffort(value) {
|
|
817
1072
|
return value && ["low", "medium", "high", "xhigh"].includes(value) ? value : undefined;
|
|
818
1073
|
}
|
package/dist/cli/status.js
CHANGED
|
@@ -3,6 +3,8 @@ import { resolve, join } from "node:path";
|
|
|
3
3
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { findResumableBranchSessions, formatBranchContinueCommand } from "../branch/resume.js";
|
|
5
5
|
import { recoverableBuildRuns } from "../buildRuns.js";
|
|
6
|
+
import { WorkflowDb, WORKFLOW_DB_FILE } from "../workflowDb.js";
|
|
7
|
+
import { formatWorkflowIssue } from "../outcomes.js";
|
|
6
8
|
function fail(message) {
|
|
7
9
|
console.error(`foreman: ${message}`);
|
|
8
10
|
process.exit(1);
|
|
@@ -18,15 +20,24 @@ export function runStatus(project) {
|
|
|
18
20
|
const projectDir = resolve(project);
|
|
19
21
|
const dir = join(projectDir, ".foreman");
|
|
20
22
|
const recoverable = recoverableBuildRuns(projectDir);
|
|
21
|
-
|
|
23
|
+
const workflowPath = join(projectDir, WORKFLOW_DB_FILE);
|
|
24
|
+
const workflow = existsSync(workflowPath) ? new WorkflowDb(projectDir, workflowPath) : undefined;
|
|
25
|
+
const activeWorkflows = workflow?.activeRuns() ?? [];
|
|
26
|
+
if (!existsSync(dir) && activeWorkflows.length === 0) {
|
|
27
|
+
workflow?.close();
|
|
22
28
|
fail(`no foreman runs found under ${dir}`);
|
|
29
|
+
}
|
|
23
30
|
const logs = readdirSync(dir)
|
|
24
31
|
.filter((f) => f.endsWith(".jsonl"))
|
|
25
32
|
.sort();
|
|
26
33
|
if (logs.length === 0) {
|
|
27
|
-
if (recoverable.length === 0)
|
|
34
|
+
if (recoverable.length === 0 && activeWorkflows.length === 0) {
|
|
35
|
+
workflow?.close();
|
|
28
36
|
fail(`no foreman runs found under ${dir}`);
|
|
37
|
+
}
|
|
29
38
|
printRecoverable(projectDir, recoverable);
|
|
39
|
+
printWorkflowIssues(workflow, activeWorkflows);
|
|
40
|
+
workflow?.close();
|
|
30
41
|
return;
|
|
31
42
|
}
|
|
32
43
|
const latest = join(dir, logs[logs.length - 1]);
|
|
@@ -79,6 +90,18 @@ export function runStatus(project) {
|
|
|
79
90
|
}
|
|
80
91
|
if (recoverable.length > 0)
|
|
81
92
|
printRecoverable(projectDir, recoverable);
|
|
93
|
+
printWorkflowIssues(workflow, activeWorkflows);
|
|
94
|
+
workflow?.close();
|
|
95
|
+
}
|
|
96
|
+
function printWorkflowIssues(workflow, runs) {
|
|
97
|
+
if (!workflow || runs.length === 0)
|
|
98
|
+
return;
|
|
99
|
+
console.log(`foreman: ${runs.length} active workflow record(s) in ${WORKFLOW_DB_FILE}`);
|
|
100
|
+
for (const run of runs) {
|
|
101
|
+
console.log(` ${run.runId} ${run.kind} ${run.status} checkpoint=${run.checkpoint}`);
|
|
102
|
+
for (const value of workflow.issues(run.runId))
|
|
103
|
+
console.log(` ${formatWorkflowIssue(value).replace(/\n/g, " | ")}`);
|
|
104
|
+
}
|
|
82
105
|
}
|
|
83
106
|
function printRecoverable(projectDir, runs) {
|
|
84
107
|
console.log(`foreman: ${runs.length} unfinished/recoverable build run(s):`);
|
package/dist/cli/tickets.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ interface PopulateCommandOptions {
|
|
|
22
22
|
sources?: string[];
|
|
23
23
|
fast?: boolean;
|
|
24
24
|
yes?: boolean;
|
|
25
|
+
authorizeRetire?: string[];
|
|
25
26
|
}
|
|
26
27
|
export declare function cmdPopulateCli(opts: PopulateCommandOptions): Promise<void>;
|
|
27
28
|
export declare function buildTicketsCommand(): Command;
|