ai-foreman 1.7.21 → 1.7.22

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/dist/activity.js CHANGED
@@ -298,8 +298,15 @@ export function reportBuilderEvent(event) {
298
298
  reporter.pulse();
299
299
  break;
300
300
  case "completed":
301
- reporter.completedWork();
302
- if (isFailedToolCompletion(event))
301
+ if (event.completionKnown !== false)
302
+ reporter.completedWork();
303
+ if (event.status === "unknown")
304
+ reporter.update("tool outcome unknown", event.name, { immediate: false });
305
+ else if (event.status === "timed_out")
306
+ reporter.update("tool timed out", event.name);
307
+ else if (event.status === "cancelled")
308
+ reporter.update("tool cancelled", event.name);
309
+ else if (isFailedToolCompletion(event))
303
310
  reporter.update("tool failed", event.name);
304
311
  else
305
312
  reporter.pulse();
@@ -78,6 +78,9 @@ export declare class ClaudeAdapter implements BuilderAdapter {
78
78
  private activeProviderTurnId?;
79
79
  private activeProviderTurnSpanId?;
80
80
  private readonly toolSpans;
81
+ private readonly toolCalls;
82
+ private readonly completedToolCalls;
83
+ private readonly unknownToolResults;
81
84
  private pending?;
82
85
  private questionTrace?;
83
86
  private responseOnlyTurn;
@@ -202,6 +202,9 @@ export class ClaudeAdapter {
202
202
  activeProviderTurnId;
203
203
  activeProviderTurnSpanId;
204
204
  toolSpans = new Map();
205
+ toolCalls = new Map();
206
+ completedToolCalls = new Set();
207
+ unknownToolResults = new Map();
205
208
  pending;
206
209
  questionTrace;
207
210
  responseOnlyTurn = false;
@@ -396,6 +399,9 @@ export class ClaudeAdapter {
396
399
  }
397
400
  else if (block.type === "tool_use") {
398
401
  const callId = block.id;
402
+ this.toolCalls.set(callId, { name: block.name, input: block.input });
403
+ if (this.toolCalls.size > 1000)
404
+ this.toolCalls.delete(this.toolCalls.keys().next().value);
399
405
  this.eventQueue.push({
400
406
  kind: "tool",
401
407
  name: block.name,
@@ -622,14 +628,35 @@ export class ClaudeAdapter {
622
628
  const callId = typeof block.tool_use_id === "string" ? block.tool_use_id : undefined;
623
629
  if (!callId)
624
630
  continue;
631
+ if (this.completedToolCalls.has(callId))
632
+ continue;
633
+ const call = this.toolCalls.get(callId);
625
634
  const spanId = this.toolSpans.get(callId);
626
635
  const text = typeof block.content === "string" ? block.content : JSON.stringify(block.content ?? "");
627
- const sanitized = text.replace(/\b(?:token|password|secret|api[_-]?key)\s*[:=]\s*\S+/gi, "$1=[REDACTED]");
636
+ const sanitized = text.replace(/\b(token|password|secret|api[_-]?key)\s*[:=]\s*\S+/gi, "$1=[REDACTED]");
628
637
  const failed = block.is_error === true;
638
+ // TIME_ELAPSED alone does not establish success or failure. Preserve it as unknown.
639
+ const elapsedOnly = /^\s*TIME_ELAPSED\s*$/.test(text);
640
+ const status = failed && /timed?\s*out|timeout/i.test(text) ? "timed_out" : failed && /cancel(?:led|ed)/i.test(text) ? "cancelled" : elapsedOnly ? "unknown" : failed ? "failed" : "completed";
641
+ if (status !== "unknown") {
642
+ this.completedToolCalls.add(callId);
643
+ if (this.completedToolCalls.size > 1000)
644
+ this.completedToolCalls.delete(this.completedToolCalls.values().next().value);
645
+ this.toolCalls.delete(callId);
646
+ }
629
647
  const outputDigest = createHash("sha256").update(text).digest("hex");
630
- this.eventQueue.push({ kind: "tool", name: "tool_result", input: {}, inputCompleteness: "unavailable", lifecycle: "completed", callId, status: failed ? "failed" : "completed", outputSummary: sanitized.slice(0, 1000), outputDigest, outputCompleteness: block.is_truncated === true ? "truncated" : "complete", rawOutputBytes: Buffer.byteLength(text), completionKnown: true, providerTurnId: this.activeProviderTurnId, eventId: randomUUID(), observedAt: new Date().toISOString() });
631
- if (spanId && this.opts.observer) {
632
- this.opts.observer.store.finishSpan(spanId, { outcome: failed ? "failed" : "completed", completionKnown: true, attributes: { callId, outputSummary: sanitized.slice(0, 1000), outputDigest } });
648
+ if (status === "unknown") {
649
+ if (this.unknownToolResults.get(callId) === outputDigest)
650
+ continue;
651
+ this.unknownToolResults.set(callId, outputDigest);
652
+ if (this.unknownToolResults.size > 1000)
653
+ this.unknownToolResults.delete(this.unknownToolResults.keys().next().value);
654
+ }
655
+ else
656
+ this.unknownToolResults.delete(callId);
657
+ this.eventQueue.push({ kind: "tool", name: call?.name ?? "unidentified tool", input: call?.input ?? {}, inputCompleteness: call ? "complete" : "unavailable", lifecycle: "completed", callId, status, outputSummary: sanitized.slice(0, 1000), outputDigest, outputCompleteness: block.is_truncated === true ? "truncated" : "complete", rawOutputBytes: Buffer.byteLength(text), completionKnown: status !== "unknown", providerTurnId: this.activeProviderTurnId, eventId: randomUUID(), observedAt: new Date().toISOString() });
658
+ if (spanId && this.opts.observer && status !== "unknown") {
659
+ this.opts.observer.store.finishSpan(spanId, { outcome: status, completionKnown: true, attributes: { callId, outputSummary: sanitized.slice(0, 1000), outputDigest } });
633
660
  this.toolSpans.delete(callId);
634
661
  }
635
662
  this.opts.observer?.signal(true);
@@ -0,0 +1,5 @@
1
+ /** Capture build input with a private index. Never stash, commit, or modify the user's index. */
2
+ export declare function captureDirtyBaseline(projectDir: string, mode: "preserve" | "include"): {
3
+ base: string;
4
+ recordPath: string;
5
+ };
@@ -0,0 +1,69 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, writeFileSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ /** Capture build input with a private index. Never stash, commit, or modify the user's index. */
6
+ export function captureDirtyBaseline(projectDir, mode) {
7
+ const git = (args, env = process.env) => execFileSync("git", args, { cwd: projectDir, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
8
+ if (git(["diff", "--name-only", "--diff-filter=U"]))
9
+ throw new Error("Resolve Git conflicts before starting an isolated build; Rafi has preserved your files.");
10
+ const head = () => { try {
11
+ return git(["rev-parse", "--verify", "HEAD"]);
12
+ }
13
+ catch {
14
+ return undefined;
15
+ } };
16
+ const parent = head();
17
+ if (!parent && mode === "preserve")
18
+ throw new Error("This repository has no committed baseline. Use --dirty-work include to capture current files privately, or make an initial commit first.");
19
+ const directory = join(projectDir, ".foreman/dirty-baselines");
20
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
21
+ const recordPath = mkdtempSync(join(directory, "baseline-"));
22
+ const indexPath = resolve(projectDir, git(["rev-parse", "--git-path", "index"]));
23
+ const indexDigest = existsSync(indexPath) ? createHash("sha256").update(readFileSync(indexPath)).digest("hex") : null;
24
+ const status = git(["status", "--porcelain", "--untracked-files=all"]);
25
+ const paths = execFileSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], { cwd: projectDir }).toString().split("\0").filter(path => path && !path.startsWith(".foreman/") && !/^\.rafi\/(recovery|observability)\.sqlite3/.test(path));
26
+ // HEAD paths are needed too: staged deletions/rename sources no longer exist in the user's index.
27
+ if (parent)
28
+ paths.push(...execFileSync("git", ["ls-tree", "-r", "--name-only", "-z", parent], { cwd: projectDir }).toString().split("\0").filter(path => path && !path.startsWith(".foreman/") && !/^\.rafi\/(recovery|observability)\.sqlite3/.test(path)));
29
+ const files = [...new Set(paths)].map(path => {
30
+ const absolute = join(projectDir, path);
31
+ if (!existsSync(absolute))
32
+ return { path, digest: null };
33
+ const stat = lstatSync(absolute);
34
+ if (stat.isDirectory()) {
35
+ if (mode === "include" && execFileSync("git", ["status", "--porcelain"], { cwd: absolute, encoding: "utf8" }).trim())
36
+ throw new Error(`Dirty submodule ${path} cannot be included automatically. Commit its work or use --dirty-work preserve.`);
37
+ return { path, digest: null, kind: "submodule" };
38
+ }
39
+ return { path, digest: createHash("sha256").update(stat.isSymbolicLink() ? readlinkSync(absolute) : readFileSync(absolute)).digest("hex"), mode: stat.mode, kind: stat.isSymbolicLink() ? "symlink" : "file" };
40
+ });
41
+ const diffBase = parent ?? execFileSync("git", ["hash-object", "-w", "-t", "tree", "--stdin"], { cwd: projectDir, input: "", encoding: "utf8" }).trim();
42
+ const diff = execFileSync("git", ["diff", diffBase, "--binary"], { cwd: projectDir });
43
+ writeFileSync(join(recordPath, "working.patch"), diff, { mode: 0o600 });
44
+ let base = parent;
45
+ if (mode === "include") {
46
+ const env = { ...process.env, GIT_INDEX_FILE: join(recordPath, "index"), GIT_AUTHOR_NAME: "Rafi baseline", GIT_AUTHOR_EMAIL: "baseline@rafi.local", GIT_COMMITTER_NAME: "Rafi baseline", GIT_COMMITTER_EMAIL: "baseline@rafi.local" };
47
+ git(parent ? ["read-tree", parent] : ["read-tree", "--empty"], env);
48
+ if (paths.length)
49
+ execFileSync("git", ["add", "-A", "--pathspec-from-file=-", "--pathspec-file-nul"], { cwd: projectDir, env: { ...env, GIT_LITERAL_PATHSPECS: "1" }, input: `${paths.join("\0")}\0`, stdio: ["pipe", "pipe", "pipe"] });
50
+ const tree = git(["write-tree"], env);
51
+ base = git(["commit-tree", tree, ...(parent ? ["-p", parent] : []), "-m", "Rafi preserved dirty build input"], env);
52
+ }
53
+ const finalIndexDigest = existsSync(indexPath) ? createHash("sha256").update(readFileSync(indexPath)).digest("hex") : null;
54
+ const changed = head() !== parent || finalIndexDigest !== indexDigest || files.some(file => {
55
+ if (file.kind === "submodule")
56
+ return false;
57
+ const absolute = join(projectDir, file.path);
58
+ if (!existsSync(absolute))
59
+ return file.digest !== null;
60
+ if (file.digest === null)
61
+ return true;
62
+ const stat = lstatSync(absolute);
63
+ return createHash("sha256").update(stat.isSymbolicLink() ? readlinkSync(absolute) : readFileSync(absolute)).digest("hex") !== file.digest || stat.mode !== file.mode;
64
+ });
65
+ if (changed)
66
+ throw new Error("Checkout or index changed while capturing the baseline. Your work is preserved; retry after those edits finish.");
67
+ writeFileSync(join(recordPath, "manifest.json"), JSON.stringify({ version: 1, mode, parent, base, indexDigest, status, files, patchDigest: createHash("sha256").update(diff).digest("hex"), createdAt: new Date().toISOString(), delivery: "isolated branches; no automatic merge into dirty checkout" }, null, 2), { mode: 0o600 });
68
+ return { base, recordPath };
69
+ }
@@ -73,7 +73,7 @@ export function findResumableBranchSessions(foremanDir) {
73
73
  }
74
74
  export function formatBranchContinueCommand(projectDir, session) {
75
75
  const args = [
76
- "ai-foreman",
76
+ "rafi",
77
77
  "start",
78
78
  shellQuote(projectDir),
79
79
  "--steps",
@@ -91,7 +91,7 @@ export function formatBranchContinueCommand(projectDir, session) {
91
91
  args.push("--model", shellQuote(session.model));
92
92
  if (session.effort)
93
93
  args.push("--effort", shellQuote(session.effort));
94
- if (session.reviewProvider)
94
+ if (session.reviewProvider && session.completionMode !== "none" && (session.completionMode || session.createPr))
95
95
  args.push("--provider", shellQuote(session.reviewProvider));
96
96
  if (session.fast)
97
97
  args.push("--fast");
@@ -18,12 +18,21 @@ export function migrateBuildWork(db, projectDir, faults) {
18
18
  }
19
19
  const lease = db.prepare("SELECT pid,process_start,host FROM project_lease").get();
20
20
  if (lease && classifyProcess(lease.pid, lease.process_start, lease.host).state !== "dead")
21
- throw new Error("Work admission upgrade requires stopped, verified writers and a consistent backup");
21
+ throw new Error(`Rafi cannot upgrade build storage while its writer may still be running (PID ${lease.pid}, host ${lease.host}). Run \`rafi status .\` and \`rafi doctor .\` to inspect it. Stop the build on its owning host before retrying; ticket show, validate, and queue remain available.`);
22
22
  const owner = db.prepare("SELECT record_json FROM build_admission").get();
23
23
  if (owner) {
24
- const identity = JSON.parse(owner.record_json);
25
- if (classifyProcess(identity.pid, identity.processStart, identity.host).state !== "dead")
26
- throw new Error("Work admission upgrade requires a stopped, verified build owner");
24
+ let identity;
25
+ try {
26
+ identity = JSON.parse(owner.record_json);
27
+ if (!identity || !Number.isSafeInteger(identity.pid) || identity.pid <= 1 || typeof identity.host !== "string" || typeof identity.processStart !== "string")
28
+ throw new Error("invalid owner fields");
29
+ }
30
+ catch {
31
+ throw new Error("Rafi cannot verify the saved build owner because its record is damaged. Run `rafi doctor .` to inspect recovery state; preserve the database before repairing it. Ticket inspection remains available.");
32
+ }
33
+ const state = classifyProcess(identity.pid, identity.processStart, identity.host).state;
34
+ if (state !== "dead")
35
+ throw new Error(`Rafi cannot upgrade build storage: the saved build owner is ${state} (PID ${identity.pid}, host ${identity.host}). Run \`rafi status .\` and \`rafi doctor .\`. Verify and stop the build on its owning host before retrying. Ticket show, validate, and queue remain available; Rafi has not taken over the build.`);
27
36
  }
28
37
  const backup = db.serialize();
29
38
  // The coherent WAL-visible backup survives even a rolled-back upgrade.
@@ -12,6 +12,7 @@ import { ObservabilityReader, ObservabilityStore } from "../observability.js";
12
12
  import { WorkflowReader } from "../workflowReader.js";
13
13
  import { WorkflowDb } from "../workflowDb.js";
14
14
  import { error, success, warning } from "../terminalStyle.js";
15
+ import { readInterviewRecords } from "../interviews.js";
15
16
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))?.version;
16
17
  function commandVersion(command) {
17
18
  const result = spawnSync(command, ["--version"], {
@@ -54,6 +55,9 @@ export function buildDoctorCommand() {
54
55
  report(existsSync(cwd), "project directory exists", cwd);
55
56
  if (!existsSync(cwd))
56
57
  process.exit(1);
58
+ const unfinishedPlanning = readInterviewRecords(cwd).records.filter(record => record.status !== "completed");
59
+ if (unfinishedPlanning.length)
60
+ warn("saved planning sessions", `${unfinishedPlanning.length} unfinished; run \`rafi plan:doctor\` to select and recover one`);
57
61
  try {
58
62
  loadConfig(join(cwd, "foreman.yaml"));
59
63
  report(true, "foreman.yaml", existsSync(join(cwd, "foreman.yaml")) ? "valid" : "not present, using defaults");
@@ -20,7 +20,7 @@ import { collectManagerProjectDiagnostics, executeManagerEvidenceRequest, MANAGE
20
20
  import { accent, sanitizeTerminalText } from "../terminalStyle.js";
21
21
  export function buildManagerCommand(options = {}) {
22
22
  return new Command("manager")
23
- .description("Inspect retained builds and enqueue explicitly scoped Builder/QA guidance.")
23
+ .description("Consult on setup, planning, tickets, builds, and QA, including before the first build.")
24
24
  .argument(options.requireProject ? "<project>" : "[project]", "project directory")
25
25
  .option("--run <run-id>", "set the initial focused build run")
26
26
  .option("--ask <question>", "ask one question and exit")
@@ -55,11 +55,11 @@ export async function runManager(projectDir, options) {
55
55
  const initialCollection = collectManagerProjectDiagnostics(projectDir, { initialFocusRunId: options.runId, question: options.ask, external: "off" });
56
56
  const initialReport = initialCollection.report;
57
57
  let currentFocusRunId = initialReport.initialFocusRunId;
58
- let referencedRunIds = [currentFocusRunId];
58
+ let referencedRunIds = currentFocusRunId ? [currentFocusRunId] : [];
59
59
  const managerLabel = (text) => accent(text, { stream: output, env: process.env, machineReadable: Boolean(options.ask) });
60
60
  output.write(`${managerLabel("rafi manager")}: ${initialReport.totalRunCount} retained build run${initialReport.totalRunCount === 1 ? "" : "s"}\n`);
61
61
  output.write(`${managerLabel("rafi manager")}: verified active run ${sanitizeTerminalText(initialReport.verifiedActiveRunId ?? "none")}\n`);
62
- output.write(`${managerLabel("rafi manager")}: initial focus ${sanitizeTerminalText(currentFocusRunId)}\n`);
62
+ output.write(`${managerLabel("rafi manager")}: initial focus ${sanitizeTerminalText(currentFocusRunId || "project setup and planning")}\n`);
63
63
  if (initialReport.staleRecoveryRunIds.length)
64
64
  output.write(`${managerLabel("rafi manager")}: stale recovery state ${initialReport.staleRecoveryRunIds.map(sanitizeTerminalText).join(", ")}\n`);
65
65
  const metadata = new ManagerSessionRecorder(projectDir);
package/dist/cli/start.js CHANGED
@@ -34,6 +34,7 @@ import { loadTickets } from "../tickets/ticketLoader.js";
34
34
  import { StateDb } from "../tickets/stateDb.js";
35
35
  import { applySharedDeliveryBranch, applySizeBranchPolicy, buildBranchAuditInstruction, buildBranchPlan, parseAuditDependencies } from "../branch/planner.js";
36
36
  import { currentGitRef, ensureCleanBaseWorktree, generatedTrackerDirtyPaths } from "../branch/git.js";
37
+ import { captureDirtyBaseline } from "../branch/dirtyBaseline.js";
37
38
  import { formatGitHubFailure, preflightGh } from "../branch/github.js";
38
39
  import { preflightGlab } from "../branch/gitlab.js";
39
40
  import { readDeliveryUnitSession, runBranchPlan } from "../branch/runner.js";
@@ -386,6 +387,8 @@ export function savedStartArguments(command, projectDir, opts) {
386
387
  }
387
388
  /** Pure validation shared by interactive start and persisted preparation. */
388
389
  export function validateStartOptions(opts, requireCount) {
390
+ if (opts.dirtyWork !== undefined && !["preserve", "include"].includes(opts.dirtyWork))
391
+ throw new Error("--dirty-work must be preserve or include");
389
392
  const recovery = Boolean(opts.resume || opts.continue || opts.recoverRun);
390
393
  const tickets = opts.ticket ?? [];
391
394
  if (!recovery && tickets.length > 1)
@@ -493,6 +496,7 @@ export function buildStartCommand() {
493
496
  .option("--builder-network", "request build-scoped builder internet access with separate explicit approval; rechecked on resume")
494
497
  .option("--builder-approvals", "allow the builder to request explicit per-operation sandbox approvals; QA stays restricted")
495
498
  .option("--keep-worktrees", "keep successful ticket worktrees for inspection")
499
+ .option("--dirty-work <mode>", "preserve | include: continue in isolated branches without merging into the dirty checkout")
496
500
  .option("--ticket <id>", "select one new ticket, or identify recovery tickets with --resume/--continue/--recover-run", collectTicket, [])
497
501
  .option("--skip-delivery-unit <id>", "skip one unfinished delivery unit for this run", collectTicket, [])
498
502
  .action(async (project, opts, command) => withBuildInvocation(async () => {
@@ -502,6 +506,11 @@ export function buildStartCommand() {
502
506
  catch (error) {
503
507
  fail(error.message);
504
508
  }
509
+ if (opts.dirtyWork) {
510
+ opts.branchPerTicket = true;
511
+ opts.completion = "none";
512
+ opts.keepWorktrees = true;
513
+ }
505
514
  const requestedTicketIds = opts.ticket ?? [];
506
515
  const recoveryIntent = Boolean(opts.resume || opts.continue || opts.recoverRun);
507
516
  if (!recoveryIntent && requestedTicketIds.length > 1)
@@ -549,6 +558,7 @@ export function buildStartCommand() {
549
558
  if (!existsSync(cwd))
550
559
  fail(`project directory not found: ${cwd}`);
551
560
  const recoveryRecord = opts.recoverRun ? readBuildRuns(cwd).find((run) => run.runId === opts.recoverRun) : undefined;
561
+ let dirtyWork = recoveryRecord?.runDecisions?.dirtyWork;
552
562
  if (opts.recoverRun && !recoveryRecord)
553
563
  fail(`recoverable build run not found: ${opts.recoverRun}`);
554
564
  const autonomyProfile = opts.autonomy;
@@ -745,6 +755,12 @@ export function buildStartCommand() {
745
755
  fail("current-branch build recovery supports exactly one --ticket");
746
756
  }
747
757
  if (branchMode) {
758
+ if (dirtyWork) {
759
+ branchDefaults.completionMode = "none";
760
+ branchDefaults.createReview = false;
761
+ branchDefaults.cleanupBranches = false;
762
+ opts.keepWorktrees = true;
763
+ }
748
764
  if (opts.resume && continueTickets.length > 1) {
749
765
  fail("--resume <sessionId> in branch mode supports exactly one --ticket; use --continue for multiple tickets");
750
766
  }
@@ -1682,7 +1698,7 @@ export function buildStartCommand() {
1682
1698
  if (branchMode) {
1683
1699
  const ticketsConfig = loadTicketsConfig(cwd);
1684
1700
  const allowedBaseDirtyPaths = generatedTrackerDirtyPaths(ticketsConfig.paths);
1685
- const baseWorktreePolicy = recoveryRecord ? "skip" : "enforce";
1701
+ let baseWorktreePolicy = recoveryRecord ? "skip" : "enforce";
1686
1702
  if (baseWorktreePolicy === "skip") {
1687
1703
  const projection = recoveryRecord ? projectBuildRecovery(cwd, recoveryRecord) : undefined;
1688
1704
  if (projection?.expectedChanges.length) {
@@ -1700,7 +1716,29 @@ export function buildStartCommand() {
1700
1716
  ensureCleanBaseWorktree(cwd, { allowedDirtyPaths: allowedBaseDirtyPaths });
1701
1717
  }
1702
1718
  catch (err) {
1703
- fail(err instanceof Error ? err.message : String(err));
1719
+ let mode = opts.dirtyWork;
1720
+ if (!mode && process.stdin.isTTY && process.stdout.isTTY && !opts.yes) {
1721
+ const choice = await select({ message: "Uncommitted work found. How should the isolated build start?", options: [
1722
+ { value: "preserve", label: "Preserve changes; build from committed code (Recommended)" },
1723
+ { value: "include", label: "Include current files as a private build baseline" },
1724
+ { value: "stop", label: "Stop for manual commit or stash" },
1725
+ ] });
1726
+ if (isCancel(choice) || choice === "stop")
1727
+ return;
1728
+ mode = String(choice);
1729
+ }
1730
+ if (mode !== "preserve" && mode !== "include")
1731
+ fail(`${err instanceof Error ? err.message : String(err)}\nContinue safely with --dirty-work preserve (committed input) or --dirty-work include (current files). Both keep your checkout and index intact and retain isolated worktrees.`);
1732
+ const baseline = captureDirtyBaseline(cwd, mode);
1733
+ dirtyWork = { mode, ...baseline };
1734
+ opts.base = baseline.base;
1735
+ branchDefaults.completionMode = "none";
1736
+ branchDefaults.createReview = false;
1737
+ branchDefaults.cleanupBranches = false;
1738
+ branchDefaults.rootBaseBranches = false;
1739
+ opts.keepWorktrees = true;
1740
+ baseWorktreePolicy = "skip";
1741
+ console.log(`rafi: ${mode} dirty-work baseline ${baseline.base}; evidence ${baseline.recordPath}; results stay in isolated branches.`);
1704
1742
  }
1705
1743
  }
1706
1744
  if (branchDefaults.createReview && branchDefaults.reviewProvider) {
@@ -1906,7 +1944,7 @@ export function buildStartCommand() {
1906
1944
  builderCapabilities, tickets: plan.nodes.map((node) => node.ticket.id), deliveryUnit: selectedStacks.length ? selectedStacks.map((stack) => stack.id).join(",") : deliveryRun?.unit.id,
1907
1945
  repositoryRoot: cwd, branchMode: branchPresentation.allocationMode, baseRef: plan.baseRef, builder: capturedBranchBuilder, qa: capturedBranchQa,
1908
1946
  frozenPolicy, autonomyProfile, qaEnabled,
1909
- runDecisions: { workMode: "branch-per-ticket", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBranchBuilder.auto_compact_threshold_percent ?? 65, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
1947
+ runDecisions: { dirtyWork, workMode: "branch-per-ticket", workModeSource, branchPrefix, branchPrefixSource: resolvedPrefix.source, autoCompactThresholdPercent: capturedBranchBuilder.auto_compact_threshold_percent ?? 65, thresholdSource: thresholdOverride === undefined ? "project" : "cli" },
1910
1948
  });
1911
1949
  activeContinuityRunId = masterRun.runId;
1912
1950
  if (requiresBuildApproval(cwd, plan.nodes.map(node => node.ticket.id), autoApprovePlanUpdates, approvalConsequences) && plan.issues.every((issue) => !issue.blocking)) {
@@ -2,7 +2,7 @@ import { Command } from "commander";
2
2
  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
- import { recoverableBuildRuns } from "../buildRuns.js";
5
+ import { readBuildRuns, recoverableBuildRuns } from "../buildRuns.js";
6
6
  import { WORKFLOW_DB_FILE } from "../workflowDb.js";
7
7
  import { WorkflowReader } from "../workflowReader.js";
8
8
  import { formatWorkflowIssue } from "../outcomes.js";
@@ -39,7 +39,7 @@ export function runStatus(project) {
39
39
  workflow?.close();
40
40
  fail(`no foreman runs found under ${dir}`);
41
41
  }
42
- const logs = readdirSync(dir)
42
+ const logs = (existsSync(dir) ? readdirSync(dir) : [])
43
43
  .filter((f) => f.endsWith(".jsonl"))
44
44
  .sort();
45
45
  if (logs.length === 0) {
@@ -63,6 +63,10 @@ export function runStatus(project) {
63
63
  const prEvents = records.filter((r) => ["pr-created", "pr-existing", "pr-failed"].includes(String(r.event)));
64
64
  const latestFailure = findLatestGitHubFailure(records);
65
65
  const batchEnd = [...records].reverse().find((r) => r.event === "batch-end");
66
+ const retainedRuns = readBuildRuns(projectDir).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
67
+ const byId = new Map(retainedRuns.map(run => [run.runId, run]));
68
+ const recordRunId = [...records].reverse().find(record => typeof record.runId === "string" && (byId.has(record.runId) || record.event === "step" || record.event === "batch-end" || String(record.event).startsWith("branch-")))?.runId;
69
+ const retainedRun = typeof recordRunId === "string" ? byId.get(recordRunId) : undefined;
66
70
  console.log(`${accent("foreman:")} latest run ${sanitizeTerminalText(logs[logs.length - 1] ?? "")}`);
67
71
  console.log(`${accent("foreman:")} ${steps.length} step record(s), ${escalations.length} escalation(s)`);
68
72
  if (branchEvents.length || prEvents.length) {
@@ -80,6 +84,9 @@ export function runStatus(project) {
80
84
  if (batchEnd.detail)
81
85
  console.log(`foreman: ${batchEnd.detail}`);
82
86
  }
87
+ else if (retainedRun?.status === "completed") {
88
+ console.log(`${accent("foreman:")} outcome — ${success("completed")} (durable build record ${sanitizeTerminalText(retainedRun.runId)})`);
89
+ }
83
90
  else {
84
91
  console.log(`${accent("ai-foreman:")} run is still in progress or did not finish`);
85
92
  }
@@ -15,8 +15,10 @@ export declare function buildPopulateAgentRunOptions(opts: {
15
15
  fast?: boolean;
16
16
  yes?: boolean;
17
17
  log: Log;
18
+ resumeSession?: string;
18
19
  }): RoleBuilderOptions;
19
20
  interface PopulateCommandOptions {
21
+ interviewId?: string;
20
22
  project?: string;
21
23
  agent?: string;
22
24
  model?: string;
@@ -25,6 +27,7 @@ interface PopulateCommandOptions {
25
27
  fast?: boolean;
26
28
  yes?: boolean;
27
29
  authorizeRetire?: string[];
30
+ resumeSession?: string;
28
31
  }
29
32
  export declare function combineRequirementSelections(selected: string[], additions: string): string[];
30
33
  export declare function cmdPopulateCli(opts: PopulateCommandOptions): Promise<void>;
@@ -54,6 +57,7 @@ export interface TicketOwnershipReceipt {
54
57
  category: "tickets";
55
58
  origin: string;
56
59
  }
60
+ export declare function ticketOwnershipReceipts(projectDir: string, commandName: string, _afterWrite: boolean): TicketOwnershipReceipt[];
57
61
  export declare function buildTicketsCommand(options?: {
58
62
  prepareOwnership?: (projectDir: string, receipts: TicketOwnershipReceipt[]) => void;
59
63
  recordOwnership?: (projectDir: string, receipts: TicketOwnershipReceipt[]) => void;
@@ -18,7 +18,7 @@ import { assertEffortLevel, createRoleBuilder, makeLogPath, readOnlyPermissionCo
18
18
  import { DEFAULT_TICKET_SETUP, configuredPlanningSources, configuredAppName, defaultAppName, detectDefaultBranch, detectPackageName, ensureRafiConfigForTicketSetup, externalSources, hasTicketSetupConfig, loadTicketSetupConfig, loadTicketSetupConfigWithDefaults, localSourcePaths, mergeTicketSetup, recommendedBuildDefaults, saveTicketSetupConfig, syncTicketLimits, ticketSourcesFromRegistry, urlSources, } from "../tickets/setupConfig.js";
19
19
  import { deactivateSource, discardStagedSourceCaptures, loadSourceRegistry, refreshSourceRegistry, registerSourceRequests, removeSource, saveSourceRegistry, } from "../sources/sourceRegistry.js";
20
20
  import { fetchAndSnapshotUrl, snapshotExternalLocalFile } from "../tickets/sourceFetch.js";
21
- import { checkpointInterview, completeInterview, createInterviewRecord, failInterview, } from "../interviews.js";
21
+ import { checkpointInterview, completeInterview, createInterviewRecord, failInterview, readInterviewRecords, resumeInterviewRecord, } from "../interviews.js";
22
22
  import { applyTicketPopulation, authorizeTicketRetirements, extractTicketPopulationProposal, materializeTicketPopulation, recoverTicketPublications, TICKET_POPULATION_PROPOSAL_END, TICKET_POPULATION_PROPOSAL_START } from "../ticketPopulation.js";
23
23
  import { loadTickets } from "../tickets/ticketLoader.js";
24
24
  import { resolveTicketPaths } from "../tickets/config.js";
@@ -164,6 +164,7 @@ export function buildPopulateAgentRunOptions(opts) {
164
164
  log: opts.log,
165
165
  permissionConfig: readOnlyPermissionConfig(),
166
166
  sandboxMode: "read-only",
167
+ resumeSessionId: opts.resumeSession,
167
168
  };
168
169
  }
169
170
  async function cmdSetupInitCli(opts) {
@@ -178,7 +179,7 @@ async function cmdSetupInitCli(opts) {
178
179
  let interview = beginTicketSetupInterview(dir, "tickets-setup-init", opts);
179
180
  if (hasTicketSetupConfig(dir)) {
180
181
  console.log("foreman tickets setup: existing setup found; opening setup:update");
181
- await cmdSetupUpdateCli(opts);
182
+ await cmdSetupUpdateCli({ ...opts, interviewId: undefined });
182
183
  const existing = loadTicketSetupConfigWithDefaults(dir);
183
184
  await continueTrackerSetup(dir, opts, existing);
184
185
  if (interview)
@@ -372,6 +373,8 @@ async function cmdSetupUpdateCli(opts) {
372
373
  }
373
374
  }
374
375
  function beginTicketSetupInterview(projectDir, workflow, opts) {
376
+ if (opts.interviewId)
377
+ return checkpointInterview(projectDir, resumeInterviewRecord(projectDir, opts.interviewId, workflow), {});
375
378
  if (!shouldPrompt(opts))
376
379
  return undefined;
377
380
  const record = createInterviewRecord({
@@ -1228,6 +1231,10 @@ export async function cmdPopulateCli(opts) {
1228
1231
  const config = loadConfig(join(dir, "foreman.yaml"));
1229
1232
  let builder;
1230
1233
  let viewer;
1234
+ const savedPlanning = opts.interviewId ? readInterviewRecords(dir).records.find(record => record.id === opts.interviewId && record.workflow === "tickets-populate" && record.status !== "completed") : undefined;
1235
+ if (opts.interviewId && !savedPlanning)
1236
+ throw new Error("Saved population session not found; run `rafi plan:doctor` to select another");
1237
+ let planningRecord = checkpointInterview(dir, savedPlanning ?? createInterviewRecord({ workflow: "tickets-populate", invocation: { projectDir: dir, ...opts }, checkpoint: "agent-run", answers: { approvedPlanPath: approvedPlan.path, contextSources }, outputs: [approvedPlan.path, ".tickets/tickets.yaml"] }), {});
1231
1238
  try {
1232
1239
  const roleBuilder = await createRoleBuilder(buildPopulateAgentRunOptions({
1233
1240
  projectDir: dir,
@@ -1237,8 +1244,10 @@ export async function cmdPopulateCli(opts) {
1237
1244
  fast: opts.fast,
1238
1245
  yes: Boolean(opts.yes),
1239
1246
  log,
1247
+ resumeSession: opts.resumeSession,
1240
1248
  }));
1241
1249
  builder = roleBuilder.builder;
1250
+ planningRecord = checkpointInterview(dir, planningRecord, { runtime: { runtime: roleBuilder.runtime, model: roleBuilder.model, sessionId: builder.sessionId() } });
1242
1251
  viewer = printEvents(builder.events());
1243
1252
  const foreman = new Foreman(builder, log, { desktop: config.notifications.enabled, terminalBell: config.notifications.terminal_bell }, false, 3, dir);
1244
1253
  foreman.setGraphPurpose("ticket-population");
@@ -1251,6 +1260,7 @@ export async function cmdPopulateCli(opts) {
1251
1260
  console.log(`foreman tickets: context sources ${contextSources.join(" ")}`);
1252
1261
  }
1253
1262
  const turn = await foreman.runInstruction(buildPopulateInstruction(approvedPlan.path, contextSources, ticketsConfig.paths.progressDoc));
1263
+ planningRecord = checkpointInterview(dir, planningRecord, { runtime: { runtime: roleBuilder.runtime, model: roleBuilder.model, sessionId: builder.sessionId() }, decisions: { ...planningRecord.decisions, proposalResponse: turn.result.text } });
1254
1264
  await builder.close();
1255
1265
  await viewer;
1256
1266
  log.write("ticket-populate", {
@@ -1261,7 +1271,7 @@ export async function cmdPopulateCli(opts) {
1261
1271
  isError: turn.result.isError,
1262
1272
  });
1263
1273
  if (turn.result.isError) {
1264
- fail(`builder turn errored: ${turn.result.text.slice(0, 200)}`);
1274
+ throw new Error(`builder turn errored: ${turn.result.text.slice(0, 200)}`);
1265
1275
  }
1266
1276
  if (turn.status.kind === "blocked") {
1267
1277
  throw new Error(`blocked — ${turn.status.reason ?? "builder reported blocked"}`);
@@ -1291,12 +1301,14 @@ export async function cmdPopulateCli(opts) {
1291
1301
  }
1292
1302
  authorizeTicketRetirements(materialized.retirements, { interactiveConfirmed: retirementsConfirmed, authorizedIds: opts.authorizeRetire, computerRun: Boolean(opts.yes) });
1293
1303
  const applied = applyTicketPopulation(dir, materialized);
1304
+ completeInterview(dir, planningRecord);
1294
1305
  console.log(`foreman tickets: populated atomically (run ${applied.runId}, transaction ${applied.transactionId})`);
1295
1306
  }
1296
1307
  catch (err) {
1308
+ failInterview(dir, planningRecord, planningRecord.checkpoint, err);
1297
1309
  await builder?.close().catch(() => { });
1298
1310
  await viewer?.catch(() => { });
1299
- fail(String(err instanceof Error ? err.message : err));
1311
+ fail(`${err instanceof Error ? err.message : String(err)}\nRecover with \`rafi plan:doctor\`.`);
1300
1312
  }
1301
1313
  }
1302
1314
  /** Resolve only the strategy. Context documents never select an import mode. */
@@ -1460,7 +1472,7 @@ function isApprovedPlanArtifact(projectDir, source, approvedDataPath) {
1460
1472
  return source.toLowerCase().endsWith(".md")
1461
1473
  && resolve(projectDir, source) === dataPath.replace(/\.json$/i, ".md");
1462
1474
  }
1463
- function ticketOwnershipReceipts(projectDir, commandName, _afterWrite) {
1475
+ export function ticketOwnershipReceipts(projectDir, commandName, _afterWrite) {
1464
1476
  const candidates = [
1465
1477
  ".tickets/config.yaml", ".tickets/tickets.yaml", ".tickets/delivery.yaml", ".tickets/tracker-rules.md",
1466
1478
  ".tickets/schema/tickets.schema.json", ".tickets/migrations/001_init.sql", ".tickets/.gitignore",
@@ -1567,13 +1579,17 @@ export function buildTicketsCommand(options = {}) {
1567
1579
  tickets.hook("preAction", (_thisCommand, actionCommand) => {
1568
1580
  const commandOptions = actionCommand.opts();
1569
1581
  const projectDir = cwd(commandOptions);
1582
+ if (["show", "validate"].includes(actionCommand.name()) || actionCommand.name() === "queue" && !commandOptions.refresh)
1583
+ return;
1570
1584
  recoverTicketPublications(projectDir);
1571
1585
  recoverTicketResetPublications(projectDir);
1572
- if (options.prepareOwnership && !["show", "validate", "queue"].includes(actionCommand.name())) {
1586
+ if (options.prepareOwnership && !["show", "validate", "queue", "plan"].includes(actionCommand.name())) {
1573
1587
  options.prepareOwnership(projectDir, ticketOwnershipReceipts(projectDir, actionCommand.name(), false));
1574
1588
  }
1575
1589
  });
1576
1590
  tickets.hook("postAction", (_thisCommand, actionCommand) => {
1591
+ if (["show", "validate", "queue", "plan"].includes(actionCommand.name()))
1592
+ return;
1577
1593
  if (!options.recordOwnership)
1578
1594
  return;
1579
1595
  const commandOptions = actionCommand.opts();
@@ -1583,6 +1599,7 @@ export function buildTicketsCommand(options = {}) {
1583
1599
  // ── setup:init / setup:update ─────────────────────────────────────────────
1584
1600
  tickets
1585
1601
  .command("setup:init")
1602
+ .option("--interview-id <id>", "recover an exact saved setup interview (normally selected by plan:doctor)")
1586
1603
  .description("Configure ticket population, supporting context, and build defaults in rafi-config.yaml.")
1587
1604
  .option("-p, --project <dir>", "project directory (default: cwd)")
1588
1605
  .option("--defaults", "skip prompts and use recommended ticket setup defaults")
@@ -1620,6 +1637,7 @@ export function buildTicketsCommand(options = {}) {
1620
1637
  });
1621
1638
  tickets
1622
1639
  .command("setup:update")
1640
+ .option("--interview-id <id>", "recover an exact saved setup interview (normally selected by plan:doctor)")
1623
1641
  .description("Update ticket population, supporting context, and build settings in rafi-config.yaml.")
1624
1642
  .option("-p, --project <dir>", "project directory (default: cwd)")
1625
1643
  .option("--defaults", "skip prompts and keep existing values unless explicit options are provided")
@@ -1716,6 +1734,8 @@ export function buildTicketsCommand(options = {}) {
1716
1734
  .option("--effort <level>", "reasoning effort level (low|medium|high|xhigh)")
1717
1735
  .option("--sources <paths...>", "supporting files, folders, URLs, or globs to check first")
1718
1736
  .option("--fast", "fast mode - lower latency")
1737
+ .option("--resume-session <id>", "resume a saved Ticket Maker session (normally selected by plan:doctor)")
1738
+ .option("--interview-id <id>", "recover an exact saved population interview (normally selected by plan:doctor)")
1719
1739
  .option("--authorize-retire <ids...>", "exact ticket IDs authorized to become obsolete in computer-run mode")
1720
1740
  .option("-y, --yes", "computer-run approval; retirements still require --authorize-retire exact IDs")
1721
1741
  .action(async (opts) => {
@@ -2231,6 +2251,8 @@ export function buildTicketsCommand(options = {}) {
2231
2251
  else {
2232
2252
  for (const r of rows) {
2233
2253
  console.log(` ${r.rank}. [${r.status}] ${r.ticket}: ${r.title} (${r.priority}, blocked: ${r.blockedBy})`);
2254
+ if (r.status === "blocked")
2255
+ console.log(` Next: ${sanitizeTerminalText(r.nextAction)}`);
2234
2256
  }
2235
2257
  }
2236
2258
  }
@@ -1,6 +1,6 @@
1
1
  export declare const INTERVIEW_STATE_VERSION = 2;
2
2
  export declare const INTERVIEW_DIRECTORY = ".rafi/interviews";
3
- export type InterviewWorkflow = "create" | "plan" | "tickets-plan" | "tickets-setup-init" | "tickets-setup-update";
3
+ export type InterviewWorkflow = "create" | "plan" | "tickets-plan" | "tickets-populate" | "tickets-setup-init" | "tickets-setup-update";
4
4
  export type InterviewStatus = "in_progress" | "paused" | "needs_review" | "completed" | "incompatible";
5
5
  export type InterviewStageStatus = "not_offered" | "offered" | "accepted" | "running" | "completed" | "skipped" | "paused" | "failed" | "cancelled";
6
6
  export interface InterviewRuntimeState {
@@ -62,6 +62,8 @@ export interface InterviewReadResult {
62
62
  problems: InterviewRecordProblem[];
63
63
  }
64
64
  export declare function interviewDirectory(projectDir: string): string;
65
+ /** Select only an exact compatible unfinished interview; never create a replacement on a typo. */
66
+ export declare function resumeInterviewRecord(projectDir: string, id: string, workflow: InterviewWorkflow): InterviewRecord;
65
67
  export declare function createInterviewRecord(input: {
66
68
  workflow: InterviewWorkflow;
67
69
  invocation: Record<string, unknown>;
@@ -14,6 +14,13 @@ const COMPLETE_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
14
14
  export function interviewDirectory(projectDir) {
15
15
  return join(resolve(projectDir), INTERVIEW_DIRECTORY);
16
16
  }
17
+ /** Select only an exact compatible unfinished interview; never create a replacement on a typo. */
18
+ export function resumeInterviewRecord(projectDir, id, workflow) {
19
+ const record = readInterviewRecords(projectDir).records.find(item => item.id === id && item.workflow === workflow && item.status !== "completed" && item.status !== "incompatible");
20
+ if (!record)
21
+ throw new Error(`Saved ${workflow} interview not found; run \`rafi plan:doctor\` to select another`);
22
+ return record;
23
+ }
17
24
  export function createInterviewRecord(input) {
18
25
  const stamp = (input.now ?? new Date()).toISOString();
19
26
  return {
@@ -15,7 +15,7 @@ export function buildManagerProjectPacket(report, question, previous, referenced
15
15
  const previousProject = previous && "projectDigest" in previous.report ? previous.report : undefined;
16
16
  const canDelta = Boolean(previousProject && previous?.currentFocusRunId === report.currentFocusRunId && previous.subject === subject);
17
17
  const payload = canDelta
18
- ? { kind: "manager-project-update", generatedAt: report.generatedAt, projectDigest: report.projectDigest, currentFocusRunId: report.currentFocusRunId, verifiedActiveRunId: report.verifiedActiveRunId, statusDistribution: report.statusDistribution, allRuns: report.allRuns, successfulCompletedRuns: report.successfulCompletedRuns, topRuns: report.topRuns, sourceCoverage: report.sourceCoverage, focusedReports: report.focusedReports.map(boundSingleReport), changed: projectDelta(previousProject, report) }
18
+ ? { kind: "manager-project-update", projectState: report.projectState, generatedAt: report.generatedAt, projectDigest: report.projectDigest, currentFocusRunId: report.currentFocusRunId, verifiedActiveRunId: report.verifiedActiveRunId, statusDistribution: report.statusDistribution, allRuns: report.allRuns, successfulCompletedRuns: report.successfulCompletedRuns, topRuns: report.topRuns, sourceCoverage: report.sourceCoverage, focusedReports: report.focusedReports.map(boundSingleReport), changed: projectDelta(previousProject, report) }
19
19
  : { kind: "manager-project-diagnostic-report", report };
20
20
  const prompt = boundProjectPrompt(payload, report, question);
21
21
  return { prompt, state: { digest: report.digest, report, subject, projectDigest: report.projectDigest, runCatalogDigest: catalogDigest, perRunDigests: Object.fromEntries(report.runCatalog.map(item => [item.runId, item.digest])), detailedReportDigests: Object.fromEntries(report.focusedReports.map(item => [item.runId, item.digest])), currentFocusRunId: report.currentFocusRunId, referencedRunIds, previousAggregateResults: [report.allRuns, report.successfulCompletedRuns] } };
@@ -47,6 +47,7 @@ export function buildManagerEvidencePacketV2(response, question) {
47
47
  function instructions(question, payload) {
48
48
  return [
49
49
  "You are the Rafi Manager. The project, not one run, is the default scope. Answer only from host-calculated evidence.",
50
+ "Use projectState to help with create, plan, tickets, validation, dirty work, and recovery before or between builds. Zero retained builds is a valid setup state. Recommend public Rafi commands, including rafi plan:doctor for saved planning failures. Never invent a build ID or issue build controls without a real run.",
50
51
  "An accepted successor has acknowledged context; that alone does not prove implementation was dispatched. Distinguish acceptance, adoption, dispatch, completion, pending human decisions, and uncertain work. Identify the worktree when code may be outside the main checkout.",
51
52
  "Identify every run-specific claim with its run ID. Distinguish verified active, stale recovery, recoverable, completed, failed, superseded, and legacy runs.",
52
53
  "For cumulative claims, report metric coverage and exclusions. Missing data is unavailable, never zero. Performance abnormality requires at least five successful completed runs.",
@@ -1,5 +1,29 @@
1
1
  import { hostname } from "node:os";
2
- import { resolve } from "node:path";
2
+ import { join, resolve } from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ import { execFileSync } from "node:child_process";
5
+ import { readInterviewRecords } from "./interviews.js";
6
+ import { cmdQueue, cmdValidate } from "./tickets/commands.js";
7
+ import { getAllTicketDetails } from "./tickets/details.js";
8
+ function collectProjectState(root) {
9
+ const state = { configPresent: existsSync(join(root, "rafi-config.yaml")), dirtyPaths: [], planningSessions: [], errors: [] };
10
+ const sessions = readInterviewRecords(root);
11
+ state.planningSessions = sessions.records.map(record => ({ id: record.id, workflow: record.workflow, status: record.status, checkpoint: record.checkpoint, updatedAt: record.updatedAt, failure: record.failure?.summary }));
12
+ state.errors.push(...sessions.problems.map(problem => `${problem.path}: ${problem.reason}`));
13
+ try {
14
+ state.dirtyPaths = execFileSync("git", ["status", "--porcelain", "--untracked-files=all"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim().split("\n").filter(Boolean).slice(0, 100);
15
+ }
16
+ catch { /* projects need not use Git */ }
17
+ if (existsSync(join(root, ".tickets/config.yaml"))) {
18
+ try {
19
+ state.tickets = { count: getAllTicketDetails(root).ticket_count, queue: cmdQueue(root), validationIssues: cmdValidate(root).issues };
20
+ }
21
+ catch (error) {
22
+ state.errors.push(String(error));
23
+ }
24
+ }
25
+ return state;
26
+ }
3
27
  import { BUILD_LEASE_STALE_MS, readBuildRuns } from "./buildRuns.js";
4
28
  import { collectManagerDiagnostics } from "./diagnostics.js";
5
29
  import { diagnosticDigest, ObservabilityReader } from "./observability.js";
@@ -33,16 +57,15 @@ export function collectManagerProjectDiagnostics(projectDir, options = {}) {
33
57
  const rollupById = new Map(rollups.map(rollup => [rollup.runId, rollup]));
34
58
  const workflowById = new Map(workflowRuns.map(run => [run.runId, run]));
35
59
  const ids = new Set([...buildById.keys(), ...storedById.keys(), ...rollupById.keys(), ...workflowById.keys()]);
36
- if (!ids.size)
37
- throw new Error("no build runs found for this project; start one with `rafi start` before opening Manager");
60
+ const projectState = collectProjectState(root);
38
61
  const defaultFocus = activeRunId && ids.has(activeRunId)
39
62
  ? activeRunId
40
- : [...ids].sort((a, b) => runUpdatedAt(b, buildById, storedById, workflowById, rollupById).localeCompare(runUpdatedAt(a, buildById, storedById, workflowById, rollupById)))[0];
63
+ : [...ids].sort((a, b) => runUpdatedAt(b, buildById, storedById, workflowById, rollupById).localeCompare(runUpdatedAt(a, buildById, storedById, workflowById, rollupById)))[0] ?? "";
41
64
  const initialFocus = options.initialFocusRunId ?? defaultFocus;
42
- if (!ids.has(initialFocus))
65
+ if (initialFocus && !ids.has(initialFocus))
43
66
  throw new Error(`build run not found: ${initialFocus}`);
44
67
  const currentFocus = options.currentFocusRunId ?? initialFocus;
45
- if (!ids.has(currentFocus))
68
+ if (currentFocus && !ids.has(currentFocus))
46
69
  throw new Error(`build run not found: ${currentFocus}`);
47
70
  const summaries = [...ids].map(runId => mergeSummary({ runId, build: buildById.get(runId), stored: storedById.get(runId), rollup: rollupById.get(runId), workflow: workflowById.get(runId), activeRunId, leaseRunId: lease?.runId, now, schemaVersion: observability.schemaVersion() }))
48
71
  .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || b.createdAt.localeCompare(a.createdAt) || a.runId.localeCompare(b.runId));
@@ -74,10 +97,11 @@ export function collectManagerProjectDiagnostics(projectDir, options = {}) {
74
97
  const staleRecoveryRunIds = summaries.filter(item => item.activeState === "stale_recovery").map(item => item.runId);
75
98
  const completedDates = summaries.filter(item => item.status === "completed").map(item => item.completedAt).filter((item) => Boolean(item)).sort();
76
99
  const createdDates = summaries.map(item => item.createdAt).sort();
77
- const projectBasis = { ids: summaries.map(item => [item.runId, item.digest]), activeRunId, initialFocus, currentFocus };
100
+ const projectBasis = { ids: summaries.map(item => [item.runId, item.digest]), activeRunId, initialFocus, currentFocus, projectState };
78
101
  const withoutDigest = {
79
102
  version: 1, generatedAt: now.toISOString(), projectDigest: diagnosticDigest(projectBasis), totalRunCount: summaries.length,
80
- dateRange: { earliestCreatedAt: createdDates[0], latestCreatedAt: createdDates.at(-1), ...(completedDates.length ? { latestCompletedAt: completedDates.at(-1) } : {}) },
103
+ projectState,
104
+ ...(createdDates.length ? { dateRange: { earliestCreatedAt: createdDates[0], latestCreatedAt: createdDates.at(-1), ...(completedDates.length ? { latestCompletedAt: completedDates.at(-1) } : {}) } } : {}),
81
105
  statusDistribution, capabilityDistribution, ...(activeRunId ? { verifiedActiveRunId: activeRunId } : {}), staleRecoveryRunIds,
82
106
  initialFocusRunId: initialFocus, currentFocusRunId: currentFocus, allRuns, successfulCompletedRuns, topRuns,
83
107
  runCatalog, omittedRunCount: Math.max(0, summaries.length - runCatalog.length), focusedReports,
@@ -71,7 +71,7 @@ export function validateAutonomyConfig(value) {
71
71
  if (deadlines !== undefined) {
72
72
  if (!deadlines || typeof deadlines !== "object" || Array.isArray(deadlines))
73
73
  throw new Error("autonomy.runtime_deadlines must be an object");
74
- rejectUnknown(deadlines, ["preparation_ms", "rpc_ms", "compaction_ms", "shutdown_ms", "turn_ms"], "autonomy.runtime_deadlines");
74
+ rejectUnknown(deadlines, ["preparation_ms", "rpc_ms", "compaction_ms", "shutdown_ms", "turn_ms", "heartbeat_ms"], "autonomy.runtime_deadlines");
75
75
  for (const [key, value] of Object.entries(deadlines))
76
76
  if (!Number.isSafeInteger(value) || Number(value) < 1 || Number(value) > (key === "compaction_ms" ? 180_000 : 86_400_000))
77
77
  throw new Error(`invalid runtime deadline ${key}`);
@@ -202,10 +202,11 @@ export async function superviseStart(projectDir, runId, policy, selection) {
202
202
  }
203
203
  catch { /* exited */ }
204
204
  };
205
- const watchdog = setInterval(() => { if (Date.now() - lastHeartbeat > 120_000) {
205
+ const heartbeatTimeout = policy.runtimeDeadlines?.heartbeat_ms ?? 120_000;
206
+ const watchdog = setInterval(() => { if (Date.now() - lastHeartbeat > heartbeatTimeout) {
206
207
  stopRequestSource = "heartbeat-timeout";
207
208
  stop();
208
- } }, 5_000);
209
+ } }, Math.min(5_000, Math.max(25, heartbeatTimeout / 4)));
209
210
  watchdog.unref();
210
211
  child.on("message", message => { if (message && typeof message === "object" && "kind" in message && message.kind === "rafi-worker-heartbeat")
211
212
  lastHeartbeat = Date.now(); });
@@ -11,7 +11,7 @@ export interface TicketContext {
11
11
  tickets: TicketDef[];
12
12
  db: StateDb;
13
13
  }
14
- export declare function openContext(projectDir: string): TicketContext;
14
+ export declare function openContext(projectDir: string, readOnly?: boolean): TicketContext;
15
15
  export interface InitOptions {
16
16
  appName?: string;
17
17
  timezone?: string;
@@ -1,4 +1,4 @@
1
- import { mkdirSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, writeFileSync, existsSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { stringify } from "yaml";
4
4
  import { DEFAULT_TICKETS_CONFIG, initDocsRoot, loadTicketsConfig, pathExistsOrSymlink, resolveTicketPaths, isTicketsInitialized, } from "./config.js";
@@ -12,15 +12,15 @@ import { loadDeliveryConfig, saveDeliveryConfig } from "./delivery.js";
12
12
  import { checkGitHubPrMerged } from "../branch/github.js";
13
13
  import { checkGitLabMrMerged } from "../branch/gitlab.js";
14
14
  import { cmdReviewRecommendations } from "./recommendations.js";
15
- export function openContext(projectDir) {
15
+ export function openContext(projectDir, readOnly = false) {
16
16
  const config = loadTicketsConfig(projectDir);
17
17
  const paths = resolveTicketPaths(config, projectDir);
18
18
  const tickets = loadTickets(paths.tickets);
19
- const db = new StateDb(paths.stateDb);
19
+ const db = new StateDb(paths.stateDb, { readOnly });
20
20
  return { config, projectDir, paths, tickets, db };
21
21
  }
22
- function withContext(projectDir, fn) {
23
- const ctx = openContext(projectDir);
22
+ function withContext(projectDir, fn, readOnly = false) {
23
+ const ctx = openContext(projectDir, readOnly);
24
24
  try {
25
25
  return fn(ctx);
26
26
  }
@@ -472,15 +472,17 @@ export function cmdValidate(projectDir) {
472
472
  return withContext(projectDir, (ctx) => {
473
473
  const states = ctx.db.getAllStates();
474
474
  const issues = runAllValidation(ctx.config, projectDir, ctx.tickets, states, ctx.db);
475
+ if (ctx.tickets.length && !existsSync(ctx.paths.stateDb))
476
+ issues.push({ pass: 2, severity: "error", message: `[state] ticket history database is missing: ${ctx.config.paths.stateDb}. Restore it before building; inspection has not recreated history or assumed completed work is new.` });
475
477
  return { issues, clean: issues.filter((i) => i.severity === "error").length === 0 };
476
- });
478
+ }, true);
477
479
  }
478
480
  // ── queue ─────────────────────────────────────────────────────────────────────
479
481
  export function cmdQueue(projectDir, limit) {
480
482
  return withContext(projectDir, (ctx) => {
481
483
  const states = ctx.db.getAllStates();
482
484
  return buildNextQueue(ctx.tickets, states, limit ?? ctx.config.viewLimit);
483
- });
485
+ }, true);
484
486
  }
485
487
  export function cmdStackQueue(projectDir, refresh = false) {
486
488
  return withContext(projectDir, (ctx) => {
@@ -506,13 +508,13 @@ export function cmdStackQueue(projectDir, refresh = false) {
506
508
  saveDeliveryConfig(projectDir, delivery);
507
509
  }
508
510
  return formatStackAwareQueue(ctx.tickets, ctx.db.getAllStates(), delivery);
509
- });
511
+ }, !refresh);
510
512
  }
511
513
  export function cmdImplementationQueue(projectDir) {
512
514
  return withContext(projectDir, (ctx) => {
513
515
  const states = ctx.db.getAllStates();
514
516
  return buildNextQueue(ctx.tickets, states, ctx.config.implementationLimit);
515
- });
517
+ }, true);
516
518
  }
517
519
  // ── archive ───────────────────────────────────────────────────────────────────
518
520
  export function cmdArchive(projectDir, _opts) {
@@ -13,7 +13,7 @@ export function getTicketDetails(projectDir, ticketId, now = new Date()) {
13
13
  const definition = tickets.find((ticket) => ticket.id === ticketId);
14
14
  if (!definition)
15
15
  throw new Error(`Ticket ${ticketId} not found. Run \`rafi tickets queue\` to list tickets.`);
16
- const db = new StateDb(paths.stateDb);
16
+ const db = new StateDb(paths.stateDb, { readOnly: true });
17
17
  try {
18
18
  const states = db.getAllStates();
19
19
  const state = states.get(ticketId) ?? pristineTicketState(ticketId, now.toISOString());
@@ -32,7 +32,7 @@ export function getAllTicketDetails(projectDir, now = new Date()) {
32
32
  const definitions = loadTickets(paths.tickets)
33
33
  .sort((left, right) => left.order - right.order || left.id.localeCompare(right.id));
34
34
  const generatedAt = now.toISOString();
35
- const db = new StateDb(paths.stateDb);
35
+ const db = new StateDb(paths.stateDb, { readOnly: true });
36
36
  try {
37
37
  const states = db.getAllStates();
38
38
  const validations = groupByTicketId(db.getValidationSnapshots(), (snapshot) => snapshot.scope);
@@ -31,7 +31,7 @@ export function buildNextQueue(ticketDefs, states, queueLimit) {
31
31
  blockedBy: blockedBy.length ? blockedBy.join(", ") : "None",
32
32
  size: ticket.size,
33
33
  risk: ticket.risk,
34
- nextAction: state?.next_action ?? (displayStatus === "blocked" && !blockedBy.length ? `Resolve blocker: ${state?.blocker_notes ?? "external prerequisite requires recovery"}` : defaultNextAction(ticket, displayStatus, blockedBy)),
34
+ nextAction: state?.next_action ?? (displayStatus === "blocked" && !blockedBy.length ? state?.blocker_notes ? `Resolve blocker: ${state.blocker_notes}` : `No blocker reason was saved. Run \`rafi status .\` to inspect interrupted builds, then \`rafi build:resume .\` if recoverable; otherwise record the prerequisite before unblocking ${ticket.id}.` : defaultNextAction(ticket, displayStatus, blockedBy)),
35
35
  requiredTests: ticket.required_tests.join("; "),
36
36
  evidence: state?.evidence ?? "N/A until implemented.",
37
37
  likelyFiles: ticket.likely_files.length ? ticket.likely_files.join(", ") : "unknown",
@@ -100,7 +100,10 @@ export interface TicketGroupValidationIssue {
100
100
  export declare function pristineTicketState(ticketId: string, now: string, actor?: string | null): TicketState;
101
101
  export declare class StateDb {
102
102
  private db;
103
- constructor(dbPath: string);
103
+ readonly readOnly: boolean;
104
+ constructor(dbPath: string, options?: {
105
+ readOnly?: boolean;
106
+ });
104
107
  private migrateObsoleteStatus;
105
108
  close(): void;
106
109
  getState(ticketId: string): TicketState | undefined;
@@ -1,5 +1,5 @@
1
1
  import Database from "better-sqlite3";
2
- import { mkdirSync } from "node:fs";
2
+ import { existsSync, mkdirSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { createHash } from "node:crypto";
5
5
  /** The only valid initial/fully-reset active state for a ticket. */
@@ -174,7 +174,16 @@ CREATE INDEX IF NOT EXISTS idx_ticket_group_members_group ON ticket_group_member
174
174
  `;
175
175
  export class StateDb {
176
176
  db;
177
- constructor(dbPath) {
177
+ readOnly;
178
+ constructor(dbPath, options = {}) {
179
+ this.readOnly = Boolean(options.readOnly);
180
+ if (this.readOnly) {
181
+ this.db = existsSync(dbPath) ? new Database(dbPath, { readonly: true, fileMustExist: true }) : new Database(":memory:");
182
+ if (!existsSync(dbPath))
183
+ this.db.exec(INIT_SQL);
184
+ this.db.pragma("query_only = ON");
185
+ return;
186
+ }
178
187
  mkdirSync(dirname(dbPath), { recursive: true });
179
188
  this.db = new Database(dbPath);
180
189
  this.db.pragma("journal_mode = WAL");
@@ -472,11 +481,15 @@ export class StateDb {
472
481
  return row ? this.getTicketGroupBySequence(row.sequence) : undefined;
473
482
  }
474
483
  listTicketGroups() {
484
+ if (this.readOnly && !this.db.prepare("SELECT 1 FROM sqlite_master WHERE name='ticket_groups'").get())
485
+ return [];
475
486
  const rows = this.db.prepare("SELECT sequence FROM ticket_groups ORDER BY sequence DESC").all();
476
487
  return rows.map((row) => this.getTicketGroupBySequence(row.sequence));
477
488
  }
478
489
  /** One-time migration for repositories whose complete ticket set predates groups. */
479
490
  ensureSyntheticLegacyGroup(definitions, now = new Date()) {
491
+ if (this.readOnly)
492
+ return undefined;
480
493
  if (definitions.length === 0 || this.listTicketGroups().length > 0)
481
494
  return undefined;
482
495
  return this.createTicketGroup({
@@ -503,6 +516,9 @@ export class StateDb {
503
516
  validateTicketGroups(knownTicketIds = []) {
504
517
  const issues = [];
505
518
  const groups = this.listTicketGroups();
519
+ // Ungrouped legacy trackers are inspected without publishing a synthetic group.
520
+ if (this.readOnly && groups.length === 0 && (!this.db.prepare("SELECT 1 FROM sqlite_master WHERE name='ticket_group_sequence_ledger'").get() || !(this.db.prepare("SELECT 1 FROM ticket_group_sequence_ledger LIMIT 1").get())))
521
+ return issues;
506
522
  const known = new Set(knownTicketIds);
507
523
  for (const group of groups) {
508
524
  if (group.members.length === 0)
@@ -57,7 +57,10 @@ export function runAllValidation(config, projectDir, ticketDefs, states, db) {
57
57
  }
58
58
  const blockedWithNone = queueRows.filter((r) => r.status === "blocked" && r.blockedBy === "None");
59
59
  for (const r of blockedWithNone) {
60
- issues.push({ pass: 3, severity: "error", message: `[queue] ${r.ticket} is blocked but Blocked By is None` });
60
+ const state = states.get(r.ticket);
61
+ if (state?.blocker_notes?.trim())
62
+ continue;
63
+ issues.push({ pass: 3, severity: "error", message: `[queue] ${r.ticket} is blocked but no blocker or reason was saved. Inspect \`rafi status .\` for interrupted builds; recover with \`rafi build:resume .\` when appropriate, or record the prerequisite with \`rafi tickets block ${r.ticket} --summary "reason"\`.` });
61
64
  }
62
65
  // Pass 4: generated Markdown doc
63
66
  const progressDocPath = join(projectDir, config.paths.progressDoc);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.7.21",
3
+ "version": "1.7.22",
4
4
  "description": "An orchestrator that keeps Codex CLI / Claude Code builders moving through their step list unattended. Part of Rafi.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -275,7 +275,7 @@
275
275
  "scripts": {
276
276
  "build": "tsc -p tsconfig.build.json && node scripts/copy-graph-assets.mjs",
277
277
  "dev": "tsx src/index.ts",
278
- "test": "tsx --test --test-concurrency=1 test/*.test.ts test/*.test.mjs",
278
+ "test": "node scripts/test-offline.mjs",
279
279
  "typecheck": "tsc --noEmit",
280
280
  "test:live-providers": "RAFI_LIVE_PROVIDER_SESSIONS=1 tsx --test test/providerSmoke.test.ts",
281
281
  "prepublishOnly": "tsc -p ../spec/tsconfig.build.json && pnpm build && pnpm --filter ai-foreman test && tsc --noEmit"
@@ -307,8 +307,8 @@
307
307
  "luxon": "^3.7.2",
308
308
  "parse5": "7.2.1",
309
309
  "pdfjs-dist": "4.10.38",
310
- "rafi-spec": "0.8.14",
311
- "special-agents": "0.8.13",
310
+ "rafi-spec": "0.8.15",
311
+ "special-agents": "0.8.14",
312
312
  "yaml": "^2.5.1"
313
313
  },
314
314
  "devDependencies": {