ai-foreman 1.7.21 → 1.7.23
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 +9 -2
- package/dist/adapters/claude.d.ts +3 -0
- package/dist/adapters/claude.js +31 -4
- package/dist/branch/dirtyBaseline.d.ts +5 -0
- package/dist/branch/dirtyBaseline.js +69 -0
- package/dist/branch/resume.js +2 -2
- package/dist/buildWorkMigration.js +13 -4
- package/dist/cli/doctor.js +4 -0
- package/dist/cli/manager.js +3 -3
- package/dist/cli/start.js +41 -3
- package/dist/cli/status.js +9 -2
- package/dist/cli/tickets.d.ts +4 -0
- package/dist/cli/tickets.js +28 -6
- package/dist/interviews.d.ts +3 -1
- package/dist/interviews.js +7 -0
- package/dist/managerPacket.js +2 -1
- package/dist/projectDiagnostics.js +32 -8
- package/dist/recoveryPolicy.js +1 -1
- package/dist/supervisedStart.js +3 -2
- package/dist/ticketPlanning.d.ts +6 -2
- package/dist/ticketPlanning.js +28 -7
- package/dist/tickets/commands.d.ts +1 -1
- package/dist/tickets/commands.js +11 -9
- package/dist/tickets/details.js +2 -2
- package/dist/tickets/queue.js +1 -1
- package/dist/tickets/stateDb.d.ts +4 -1
- package/dist/tickets/stateDb.js +18 -2
- package/dist/tickets/validate.js +4 -1
- package/package.json +4 -4
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
|
-
|
|
302
|
-
|
|
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;
|
package/dist/adapters/claude.js
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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,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
|
+
}
|
package/dist/branch/resume.js
CHANGED
|
@@ -73,7 +73,7 @@ export function findResumableBranchSessions(foremanDir) {
|
|
|
73
73
|
}
|
|
74
74
|
export function formatBranchContinueCommand(projectDir, session) {
|
|
75
75
|
const args = [
|
|
76
|
-
"
|
|
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(
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
package/dist/cli/doctor.js
CHANGED
|
@@ -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");
|
package/dist/cli/manager.js
CHANGED
|
@@ -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("
|
|
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
|
-
|
|
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
|
-
|
|
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)) {
|
package/dist/cli/status.js
CHANGED
|
@@ -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
|
}
|
package/dist/cli/tickets.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/tickets.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
}
|
package/dist/interviews.d.ts
CHANGED
|
@@ -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>;
|
package/dist/interviews.js
CHANGED
|
@@ -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 {
|
package/dist/managerPacket.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/recoveryPolicy.js
CHANGED
|
@@ -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}`);
|
package/dist/supervisedStart.js
CHANGED
|
@@ -202,10 +202,11 @@ export async function superviseStart(projectDir, runId, policy, selection) {
|
|
|
202
202
|
}
|
|
203
203
|
catch { /* exited */ }
|
|
204
204
|
};
|
|
205
|
-
const
|
|
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(); });
|
package/dist/ticketPlanning.d.ts
CHANGED
|
@@ -64,11 +64,15 @@ export interface TicketPlanningContext {
|
|
|
64
64
|
}>;
|
|
65
65
|
existingNext: string[];
|
|
66
66
|
}
|
|
67
|
+
/** The tracker facts needed to validate a proposal before it is approved. */
|
|
68
|
+
export interface TicketPlanValidationContext {
|
|
69
|
+
futureWorkIds?: Iterable<number>;
|
|
70
|
+
}
|
|
67
71
|
export declare function readTicketPlanningContext(projectDir: string): TicketPlanningContext;
|
|
68
72
|
export declare function ticketPlanningFingerprint(projectDir: string): PlanningFingerprint[];
|
|
69
73
|
export declare function planningFingerprintChanges(projectDir: string, expected: PlanningFingerprint[]): string[];
|
|
70
|
-
export declare function extractTicketPlanProposal(output: string, existingTickets?: TicketDef[]): TicketPlanProposal;
|
|
71
|
-
export declare function validateTicketPlanProposal(value: unknown, existingTickets?: TicketDef[]): string[];
|
|
74
|
+
export declare function extractTicketPlanProposal(output: string, existingTickets?: TicketDef[], context?: TicketPlanValidationContext): TicketPlanProposal;
|
|
75
|
+
export declare function validateTicketPlanProposal(value: unknown, existingTickets?: TicketDef[], context?: TicketPlanValidationContext): string[];
|
|
72
76
|
export interface ApplyTicketPlanOptions {
|
|
73
77
|
expectedFingerprint: PlanningFingerprint[];
|
|
74
78
|
docsRoot: string;
|
package/dist/ticketPlanning.js
CHANGED
|
@@ -12,6 +12,9 @@ import { normalizeDeliveryConfig, validateDeliveryConfig } from "./tickets/deliv
|
|
|
12
12
|
import { loadTicketSetupConfig, mergeTicketSetup, saveTicketSetupConfig } from "./tickets/setupConfig.js";
|
|
13
13
|
export const PROPOSAL_START = "RAFI_PROPOSAL_START";
|
|
14
14
|
export const PROPOSAL_END = "RAFI_PROPOSAL_END";
|
|
15
|
+
const TICKET_STATUSES = new Set([
|
|
16
|
+
"planned", "next", "in_progress", "blocked", "done", "canceled", "obsolete",
|
|
17
|
+
]);
|
|
15
18
|
export function readTicketPlanningContext(projectDir) {
|
|
16
19
|
const config = loadTicketsConfig(projectDir);
|
|
17
20
|
const paths = resolveTicketPaths(config, projectDir);
|
|
@@ -41,7 +44,7 @@ export function ticketPlanningFingerprint(projectDir) {
|
|
|
41
44
|
export function planningFingerprintChanges(projectDir, expected) {
|
|
42
45
|
return expected.filter((item) => fileHash(join(projectDir, item.path)) !== item.sha256).map((item) => item.path);
|
|
43
46
|
}
|
|
44
|
-
export function extractTicketPlanProposal(output, existingTickets = []) {
|
|
47
|
+
export function extractTicketPlanProposal(output, existingTickets = [], context = {}) {
|
|
45
48
|
const start = output.lastIndexOf(PROPOSAL_START);
|
|
46
49
|
const end = output.lastIndexOf(PROPOSAL_END);
|
|
47
50
|
if (start < 0 || end <= start)
|
|
@@ -54,12 +57,12 @@ export function extractTicketPlanProposal(output, existingTickets = []) {
|
|
|
54
57
|
catch (err) {
|
|
55
58
|
throw new Error(`planner proposal is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
56
59
|
}
|
|
57
|
-
const issues = validateTicketPlanProposal(parsed, existingTickets);
|
|
60
|
+
const issues = validateTicketPlanProposal(parsed, existingTickets, context);
|
|
58
61
|
if (issues.length)
|
|
59
62
|
throw new Error(`invalid planner proposal:\n${issues.map((issue) => `- ${issue}`).join("\n")}`);
|
|
60
63
|
return parsed;
|
|
61
64
|
}
|
|
62
|
-
export function validateTicketPlanProposal(value, existingTickets = []) {
|
|
65
|
+
export function validateTicketPlanProposal(value, existingTickets = [], context = {}) {
|
|
63
66
|
const issues = [];
|
|
64
67
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
65
68
|
return ["proposal must be an object"];
|
|
@@ -109,9 +112,26 @@ export function validateTicketPlanProposal(value, existingTickets = []) {
|
|
|
109
112
|
if (!ids.has(id))
|
|
110
113
|
issues.push(`supersession references unknown ticket ${id}`);
|
|
111
114
|
}
|
|
112
|
-
for (const change of p.state_changes)
|
|
113
|
-
if (!ids.has(change.id) || !change.reason?.trim())
|
|
114
|
-
issues.push(`invalid state change for ${change.id}`);
|
|
115
|
+
for (const change of p.state_changes) {
|
|
116
|
+
if (!change || typeof change !== "object" || !ids.has(change.id) || !change.reason?.trim()) {
|
|
117
|
+
issues.push(`invalid state change for ${String(change && typeof change === "object" ? change.id : undefined)}`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!TICKET_STATUSES.has(change.status))
|
|
121
|
+
issues.push(`state change for ${change.id} has invalid status ${String(change.status)}`);
|
|
122
|
+
}
|
|
123
|
+
const futureWorkIds = context.futureWorkIds ? new Set(context.futureWorkIds) : undefined;
|
|
124
|
+
for (const disposition of p.future_work) {
|
|
125
|
+
if (!disposition || typeof disposition !== "object" || !Number.isInteger(disposition.id)) {
|
|
126
|
+
issues.push(`future work disposition has invalid id ${String(disposition && typeof disposition === "object" ? disposition.id : undefined)}`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (!["accepted", "rejected", "merged", "queued"].includes(disposition.disposition)) {
|
|
130
|
+
issues.push(`future work item ${disposition.id} has invalid disposition ${String(disposition.disposition)}`);
|
|
131
|
+
}
|
|
132
|
+
if (futureWorkIds && !futureWorkIds.has(disposition.id))
|
|
133
|
+
issues.push(`future work item ${disposition.id} does not exist`);
|
|
134
|
+
}
|
|
115
135
|
for (const item of p.source_reconciliation) {
|
|
116
136
|
if (!item.source?.trim() || !item.item?.trim())
|
|
117
137
|
issues.push("source reconciliation needs source and item");
|
|
@@ -151,7 +171,8 @@ export function applyApprovedTicketPlan(projectDir, proposal, opts) {
|
|
|
151
171
|
const existing = loadTickets(paths.tickets);
|
|
152
172
|
if (existing.length > 0 && !existsSync(paths.stateDb))
|
|
153
173
|
throw new Error("tickets exist but the ignored ticket-state database is missing; restore it before planning so completed work is not treated as new");
|
|
154
|
-
const
|
|
174
|
+
const currentContext = readTicketPlanningContext(root);
|
|
175
|
+
const proposalIssues = validateTicketPlanProposal(proposal, existing, { futureWorkIds: currentContext.futureWork.flatMap((item) => typeof item.id === "number" ? [item.id] : []) });
|
|
155
176
|
if (proposalIssues.length)
|
|
156
177
|
throw new Error(`approved proposal is invalid:\n${proposalIssues.map((issue) => `- ${issue}`).join("\n")}`);
|
|
157
178
|
const lockPath = join(root, ".tickets", "planning.lock");
|
|
@@ -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;
|
package/dist/tickets/commands.js
CHANGED
|
@@ -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) {
|
package/dist/tickets/details.js
CHANGED
|
@@ -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);
|
package/dist/tickets/queue.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|
package/dist/tickets/stateDb.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/tickets/validate.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "1.7.23",
|
|
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": "
|
|
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.
|
|
311
|
-
"special-agents": "0.8.
|
|
310
|
+
"rafi-spec": "0.8.15",
|
|
311
|
+
"special-agents": "0.8.14",
|
|
312
312
|
"yaml": "^2.5.1"
|
|
313
313
|
},
|
|
314
314
|
"devDependencies": {
|