ai-foreman 1.7.12 → 1.7.13

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.
Files changed (73) hide show
  1. package/README.md +15 -0
  2. package/dist/activity.d.ts +5 -1
  3. package/dist/activity.js +1 -1
  4. package/dist/adapters/claude.d.ts +7 -0
  5. package/dist/adapters/claude.js +133 -7
  6. package/dist/adapters/codex.d.ts +5 -0
  7. package/dist/adapters/codex.js +121 -11
  8. package/dist/adapters/recovering.d.ts +2 -0
  9. package/dist/adapters/recovering.js +11 -1
  10. package/dist/adapters/types.d.ts +47 -1
  11. package/dist/agentRun.d.ts +2 -0
  12. package/dist/agentRun.js +7 -1
  13. package/dist/branch/resume.js +11 -1
  14. package/dist/branch/runner.d.ts +11 -3
  15. package/dist/branch/runner.js +114 -60
  16. package/dist/buildRuns.d.ts +6 -2
  17. package/dist/buildRuns.js +76 -32
  18. package/dist/cli/doctor.d.ts +7 -0
  19. package/dist/cli/doctor.js +84 -1
  20. package/dist/cli/manager.d.ts +17 -0
  21. package/dist/cli/manager.js +135 -0
  22. package/dist/cli/recovery.d.ts +4 -0
  23. package/dist/cli/recovery.js +80 -0
  24. package/dist/cli/start.js +289 -53
  25. package/dist/cli/status.js +13 -2
  26. package/dist/cli/tickets.js +26 -8
  27. package/dist/config.d.ts +2 -0
  28. package/dist/config.js +23 -0
  29. package/dist/continuity.js +14 -9
  30. package/dist/diagnostics.d.ts +25 -0
  31. package/dist/diagnostics.js +428 -0
  32. package/dist/foreman.d.ts +16 -6
  33. package/dist/foreman.js +70 -82
  34. package/dist/handoffs.d.ts +5 -1
  35. package/dist/handoffs.js +4 -1
  36. package/dist/index.js +6 -0
  37. package/dist/log.d.ts +2 -2
  38. package/dist/log.js +4 -1
  39. package/dist/managerPacket.d.ts +25 -0
  40. package/dist/managerPacket.js +83 -0
  41. package/dist/markers.d.ts +1 -1
  42. package/dist/markers.js +4 -1
  43. package/dist/observability.d.ts +248 -0
  44. package/dist/observability.js +1018 -0
  45. package/dist/planChanges.d.ts +4 -0
  46. package/dist/planChanges.js +16 -0
  47. package/dist/projectDiagnostics.d.ts +27 -0
  48. package/dist/projectDiagnostics.js +407 -0
  49. package/dist/qaRecovery.d.ts +140 -0
  50. package/dist/qaRecovery.js +595 -0
  51. package/dist/qaReview.d.ts +128 -14
  52. package/dist/qaReview.js +974 -57
  53. package/dist/qaSnapshot.d.ts +31 -5
  54. package/dist/qaSnapshot.js +209 -134
  55. package/dist/recoveryPolicy.d.ts +37 -0
  56. package/dist/recoveryPolicy.js +251 -0
  57. package/dist/roles.js +1 -0
  58. package/dist/supervisor.d.ts +40 -0
  59. package/dist/supervisor.js +102 -0
  60. package/dist/ticketHistoryReader.d.ts +19 -0
  61. package/dist/ticketHistoryReader.js +58 -0
  62. package/dist/tickets/details.d.ts +8 -0
  63. package/dist/tickets/details.js +59 -11
  64. package/dist/tickets/output.d.ts +5 -0
  65. package/dist/tickets/output.js +73 -0
  66. package/dist/tickets/stateDb.d.ts +1 -0
  67. package/dist/tickets/stateDb.js +5 -0
  68. package/dist/workflowDb.d.ts +31 -2
  69. package/dist/workflowDb.js +192 -23
  70. package/dist/workflowReader.d.ts +42 -0
  71. package/dist/workflowReader.js +171 -0
  72. package/foreman.yaml +9 -0
  73. package/package.json +51 -3
@@ -1,3 +1,22 @@
1
+ export interface QaUntrackedCapture {
2
+ path: string;
3
+ kind: "file" | "symlink";
4
+ mode: number;
5
+ digest: string;
6
+ bytes: Buffer;
7
+ }
8
+ /** Immutable host-observable product state from which QA is constructed. */
9
+ export interface FrozenQaSourceState {
10
+ head: string;
11
+ status: Buffer;
12
+ combinedDiff: Buffer;
13
+ stagedDiff: Buffer;
14
+ unstagedDiff: Buffer;
15
+ changeSummary: string;
16
+ untracked: QaUntrackedCapture[];
17
+ digest: string;
18
+ capturedAt: string;
19
+ }
1
20
  export interface QaChangeManifest {
2
21
  diffDigest: string;
3
22
  untracked: Array<{
@@ -10,6 +29,7 @@ export interface QaChangeManifest {
10
29
  export interface DisposableQaSnapshot {
11
30
  path: string;
12
31
  manifest: QaChangeManifest;
32
+ frozenState: FrozenQaSourceState;
13
33
  verify(): void;
14
34
  qaChanges(): string[];
15
35
  remove(): void;
@@ -17,17 +37,23 @@ export interface DisposableQaSnapshot {
17
37
  export interface AsyncDisposableQaSnapshot {
18
38
  path: string;
19
39
  manifest: QaChangeManifest;
40
+ frozenState: FrozenQaSourceState;
20
41
  verify(): Promise<void>;
21
42
  qaChanges(): Promise<string[]>;
22
43
  remove(): Promise<void>;
23
44
  }
24
45
  export type QaSnapshotProgress = (state: string, detail?: string) => void;
25
- /** Reproduce the Builder's exact change set in a detached disposable worktree. */
46
+ export declare class QaSourceInstabilityError extends Error {
47
+ readonly attempts: number;
48
+ constructor(attempts: number);
49
+ }
50
+ /** Capture twice and accept only a byte-identical source state. */
51
+ export declare function captureFrozenQaSource(worktree: string): FrozenQaSourceState;
52
+ export declare function captureFrozenQaSourceAsync(worktree: string, progress?: QaSnapshotProgress): Promise<FrozenQaSourceState>;
26
53
  export declare function createDisposableQaSnapshot(builderWorktree: string): DisposableQaSnapshot;
27
- /**
28
- * Async form used by the live CLI. Git and filesystem work yields to the event
29
- * loop so the activity reporter can keep animating during large snapshots.
30
- */
31
54
  export declare function createDisposableQaSnapshotAsync(builderWorktree: string, progress?: QaSnapshotProgress): Promise<AsyncDisposableQaSnapshot>;
55
+ export declare function frozenQaStateManifest(state: FrozenQaSourceState): QaChangeManifest;
32
56
  export declare function changeManifest(worktree: string): QaChangeManifest;
33
57
  export declare function changeManifestAsync(worktree: string, progress?: QaSnapshotProgress, state?: string): Promise<QaChangeManifest>;
58
+ export declare function deterministicChangeSummaryAsync(worktree: string): Promise<string>;
59
+ export declare function calculateFrozenQaStateDigest(state: Omit<FrozenQaSourceState, "digest" | "capturedAt">): string;
@@ -1,99 +1,80 @@
1
1
  import { createHash } from "node:crypto";
2
- import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
3
- import { cp, lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink } from "node:fs/promises";
2
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
3
+ import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises";
4
5
  import { tmpdir } from "node:os";
5
6
  import { dirname, join, resolve } from "node:path";
6
- import { execFileSync, spawn, spawnSync } from "node:child_process";
7
- /** Reproduce the Builder's exact change set in a detached disposable worktree. */
7
+ const PRODUCT_PATHSPEC = ["--", ".", ":(exclude).foreman/**", ":(exclude).rafi/cache/**"];
8
+ const MAX_CAPTURE_ATTEMPTS = 3; // initial attempt plus two bounded retries
9
+ export class QaSourceInstabilityError extends Error {
10
+ attempts;
11
+ constructor(attempts) {
12
+ super(`Builder source changed during frozen QA capture after ${attempts} attempts`);
13
+ this.attempts = attempts;
14
+ this.name = "QaSourceInstabilityError";
15
+ }
16
+ }
17
+ /** Capture twice and accept only a byte-identical source state. */
18
+ export function captureFrozenQaSource(worktree) {
19
+ const cwd = resolve(worktree);
20
+ for (let attempt = 1; attempt <= MAX_CAPTURE_ATTEMPTS; attempt++) {
21
+ const first = captureOnce(cwd);
22
+ const second = captureOnce(cwd);
23
+ if (first.digest === second.digest)
24
+ return { ...first, capturedAt: new Date().toISOString() };
25
+ }
26
+ throw new QaSourceInstabilityError(MAX_CAPTURE_ATTEMPTS);
27
+ }
28
+ export async function captureFrozenQaSourceAsync(worktree, progress = () => { }) {
29
+ const cwd = resolve(worktree);
30
+ for (let attempt = 1; attempt <= MAX_CAPTURE_ATTEMPTS; attempt++) {
31
+ progress("freezing Builder source state", `integrity pass ${attempt}/3`);
32
+ const first = await captureOnceAsync(cwd, progress);
33
+ const second = await captureOnceAsync(cwd, progress);
34
+ if (first.digest === second.digest)
35
+ return { ...first, capturedAt: new Date().toISOString() };
36
+ }
37
+ throw new QaSourceInstabilityError(MAX_CAPTURE_ATTEMPTS);
38
+ }
8
39
  export function createDisposableQaSnapshot(builderWorktree) {
9
40
  const source = resolve(builderWorktree);
10
- const root = execFileSync("git", ["-C", source, "rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
41
+ const root = gitText(source, ["rev-parse", "--show-toplevel"]);
42
+ const frozenState = captureFrozenQaSource(source);
11
43
  const tempRoot = mkdtempSync(join(tmpdir(), "rafi-qa-"));
12
44
  const review = join(tempRoot, "review");
13
- const head = execFileSync("git", ["-C", source, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
14
- const added = spawnSync("git", ["-C", root, "worktree", "add", "--detach", review, head], { encoding: "utf8" });
15
- if (added.status !== 0) {
16
- rmSync(tempRoot, { recursive: true, force: true });
17
- throw new Error(`cannot create disposable QA worktree: ${added.stderr.trim()}`);
18
- }
45
+ addWorktreeSync(root, review, frozenState.head);
19
46
  try {
20
- const diff = execFileSync("git", ["-C", source, "diff", "--binary", "HEAD"], { encoding: "buffer", maxBuffer: 128 * 1024 * 1024 });
21
- if (diff.length) {
22
- const applied = spawnSync("git", ["-C", review, "apply", "--index", "--binary", "--whitespace=nowarn", "-"], { input: diff, encoding: "buffer", maxBuffer: 128 * 1024 * 1024 });
23
- if (applied.status !== 0)
24
- throw new Error(`cannot apply Builder diff to QA worktree: ${Buffer.from(applied.stderr).toString().trim()}`);
25
- }
26
- for (const path of untrackedPaths(source))
27
- copyUntracked(source, review, path);
28
- const manifest = changeManifest(source);
47
+ applyFrozenSync(review, frozenState);
48
+ const manifest = manifestFromFrozen(frozenState);
29
49
  const snapshot = {
30
- path: review, manifest,
31
- verify: () => {
32
- const actual = changeManifest(review);
33
- if (JSON.stringify(actual) !== JSON.stringify(manifest))
34
- throw new Error(`disposable QA snapshot does not match Builder change manifest: expected ${JSON.stringify(manifest)}, got ${JSON.stringify(actual)}`);
35
- },
50
+ path: review, manifest, frozenState,
51
+ verify: () => assertSnapshotMatches(review, manifest),
36
52
  qaChanges: () => manifestDifference(manifest, changeManifest(review)),
37
- remove: () => {
38
- spawnSync("git", ["-C", root, "worktree", "remove", "--force", review], { encoding: "utf8" });
39
- rmSync(tempRoot, { recursive: true, force: true });
40
- },
53
+ remove: () => removeWorktreeSync(root, review, tempRoot),
41
54
  };
42
55
  snapshot.verify();
43
56
  return snapshot;
44
57
  }
45
58
  catch (error) {
46
- spawnSync("git", ["-C", root, "worktree", "remove", "--force", review], { encoding: "utf8" });
47
- rmSync(tempRoot, { recursive: true, force: true });
59
+ removeWorktreeSync(root, review, tempRoot);
48
60
  throw error;
49
61
  }
50
62
  }
51
- /**
52
- * Async form used by the live CLI. Git and filesystem work yields to the event
53
- * loop so the activity reporter can keep animating during large snapshots.
54
- */
55
63
  export async function createDisposableQaSnapshotAsync(builderWorktree, progress = () => { }) {
56
64
  const source = resolve(builderWorktree);
57
65
  progress("preparing disposable QA snapshot", "locating Builder worktree");
58
66
  const root = (await runGit(source, ["rev-parse", "--show-toplevel"])).toString().trim();
67
+ const frozenState = await captureFrozenQaSourceAsync(source, progress);
59
68
  const tempRoot = await mkdtemp(join(tmpdir(), "rafi-qa-"));
60
69
  const review = join(tempRoot, "review");
61
- const head = (await runGit(source, ["rev-parse", "HEAD"])).toString().trim();
62
- progress("preparing disposable QA snapshot", "creating detached review worktree");
63
- try {
64
- await runGit(root, ["worktree", "add", "--detach", review, head]);
65
- }
66
- catch (error) {
67
- await rm(tempRoot, { recursive: true, force: true });
68
- throw new Error(`cannot create disposable QA worktree: ${errorMessage(error)}`);
69
- }
70
70
  try {
71
- progress("preparing disposable QA snapshot", "copying tracked Builder changes");
72
- const diff = await runGit(source, ["diff", "--binary", "HEAD"]);
73
- if (diff.length) {
74
- try {
75
- await runGit(review, ["apply", "--index", "--binary", "--whitespace=nowarn", "-"], diff);
76
- }
77
- catch (error) {
78
- throw new Error(`cannot apply Builder diff to QA worktree: ${errorMessage(error)}`);
79
- }
80
- }
81
- const paths = await untrackedPathsAsync(source);
82
- for (let index = 0; index < paths.length; index++) {
83
- progress("preparing disposable QA snapshot", `copying untracked files ${index + 1}/${paths.length}`);
84
- await copyUntrackedAsync(source, review, paths[index]);
85
- }
86
- progress("preparing disposable QA snapshot", "recording Builder change manifest");
87
- const manifest = await changeManifestAsync(source, progress, "preparing disposable QA snapshot");
71
+ progress("preparing disposable QA snapshot", "creating detached review worktree");
72
+ await runGit(root, ["worktree", "add", "--detach", review, frozenState.head]);
73
+ await applyFrozenAsync(review, frozenState, progress);
74
+ const manifest = manifestFromFrozen(frozenState);
88
75
  const snapshot = {
89
- path: review,
90
- manifest,
91
- verify: async () => {
92
- const actual = await changeManifestAsync(review, progress, "preparing disposable QA snapshot");
93
- if (JSON.stringify(actual) !== JSON.stringify(manifest)) {
94
- throw new Error(`disposable QA snapshot does not match Builder change manifest: expected ${JSON.stringify(manifest)}, got ${JSON.stringify(actual)}`);
95
- }
96
- },
76
+ path: review, manifest, frozenState,
77
+ verify: async () => assertSnapshotMatchesAsync(review, manifest, progress),
97
78
  qaChanges: async () => manifestDifference(manifest, await changeManifestAsync(review, progress)),
98
79
  remove: async () => {
99
80
  progress("cleaning up disposable QA snapshot", "removing detached review worktree");
@@ -101,7 +82,7 @@ export async function createDisposableQaSnapshotAsync(builderWorktree, progress
101
82
  await rm(tempRoot, { recursive: true, force: true });
102
83
  },
103
84
  };
104
- progress("preparing disposable QA snapshot", "verifying review copy");
85
+ progress("preparing disposable QA snapshot", "verifying frozen review copy");
105
86
  await snapshot.verify();
106
87
  return snapshot;
107
88
  }
@@ -111,85 +92,156 @@ export async function createDisposableQaSnapshotAsync(builderWorktree, progress
111
92
  throw error;
112
93
  }
113
94
  }
95
+ export function frozenQaStateManifest(state) { return manifestFromFrozen(state); }
114
96
  export function changeManifest(worktree) {
115
- const diff = execFileSync("git", ["-C", worktree, "diff", "--binary", "HEAD"], { encoding: "buffer", maxBuffer: 128 * 1024 * 1024 });
116
- const untracked = untrackedPaths(worktree).map((path) => {
117
- const absolute = join(worktree, path);
118
- const stat = lstatSync(absolute);
119
- const kind = stat.isSymbolicLink() ? "symlink" : "file";
120
- const bytes = kind === "symlink" ? Buffer.from(readlinkSync(absolute)) : readFileSync(absolute);
121
- return { path, kind, mode: stat.mode & 0o7777, digest: createHash("sha256").update(bytes).digest("hex") };
122
- });
123
- return { diffDigest: createHash("sha256").update(diff).digest("hex"), untracked };
97
+ const diff = gitBuffer(resolve(worktree), ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
98
+ return { diffDigest: hash(diff), untracked: untrackedPaths(worktree).map((path) => describeUntrackedSync(worktree, path)) };
124
99
  }
125
100
  export async function changeManifestAsync(worktree, progress = () => { }, state = "checking QA file changes") {
126
- const diff = await runGit(worktree, ["diff", "--binary", "HEAD"]);
101
+ const diff = await runGit(worktree, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
127
102
  const paths = await untrackedPathsAsync(worktree);
128
103
  const untracked = [];
129
104
  for (let index = 0; index < paths.length; index++) {
130
- const path = paths[index];
131
105
  progress(state, `hashing untracked files ${index + 1}/${paths.length}`);
132
- const absolute = join(worktree, path);
106
+ const absolute = join(worktree, paths[index]);
133
107
  const stat = await lstat(absolute);
134
108
  const kind = stat.isSymbolicLink() ? "symlink" : "file";
135
109
  const bytes = kind === "symlink" ? Buffer.from(await readlink(absolute)) : await readFile(absolute);
136
- untracked.push({ path, kind, mode: stat.mode & 0o7777, digest: createHash("sha256").update(bytes).digest("hex") });
110
+ untracked.push({ path: paths[index], kind, mode: stat.mode & 0o7777, digest: hash(bytes) });
137
111
  }
138
- return { diffDigest: createHash("sha256").update(diff).digest("hex"), untracked };
139
- }
140
- function untrackedPaths(worktree) {
141
- const raw = execFileSync("git", ["-C", worktree, "ls-files", "--others", "--exclude-standard", "-z"], { encoding: "buffer" });
142
- return raw.toString().split("\0").filter(Boolean).sort();
143
- }
144
- async function untrackedPathsAsync(worktree) {
145
- const raw = await runGit(worktree, ["ls-files", "--others", "--exclude-standard", "-z"]);
146
- return raw.toString().split("\0").filter(Boolean).sort();
147
- }
148
- function copyUntracked(source, target, path) {
149
- const from = join(source, path);
150
- const to = join(target, path);
151
- mkdirSync(dirname(to), { recursive: true });
152
- const stat = lstatSync(from);
153
- if (stat.isSymbolicLink()) {
154
- if (existsSync(to))
155
- rmSync(to, { force: true });
156
- symlinkSync(readlinkSync(from), to);
112
+ return { diffDigest: hash(diff), untracked };
113
+ }
114
+ export async function deterministicChangeSummaryAsync(worktree) {
115
+ const [staged, unstaged, untracked] = await Promise.all([
116
+ runGit(worktree, ["diff", "--cached", "--name-status", "-z", ...PRODUCT_PATHSPEC]),
117
+ runGit(worktree, ["diff", "--name-status", "-z", ...PRODUCT_PATHSPEC]),
118
+ runGit(worktree, ["ls-files", "--others", "--exclude-standard", "-z"]),
119
+ ]);
120
+ return renderSummary(staged, unstaged, untracked);
121
+ }
122
+ function captureOnce(cwd) {
123
+ const head = gitText(cwd, ["rev-parse", "HEAD"]);
124
+ const status = gitBuffer(cwd, ["status", "--porcelain=v2", "-z", "--untracked-files=all", ...PRODUCT_PATHSPEC]);
125
+ const combinedDiff = gitBuffer(cwd, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
126
+ const stagedDiff = gitBuffer(cwd, ["diff", "--cached", "--binary", ...PRODUCT_PATHSPEC]);
127
+ const unstagedDiff = gitBuffer(cwd, ["diff", "--binary", ...PRODUCT_PATHSPEC]);
128
+ const stagedNames = gitBuffer(cwd, ["diff", "--cached", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
129
+ const unstagedNames = gitBuffer(cwd, ["diff", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
130
+ const untrackedNames = gitBuffer(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]);
131
+ const untracked = untrackedPathsFrom(untrackedNames).map((path) => captureUntrackedSync(cwd, path));
132
+ const base = { head, status, combinedDiff, stagedDiff, unstagedDiff, changeSummary: renderSummary(stagedNames, unstagedNames, untrackedNames), untracked };
133
+ return { ...base, digest: calculateFrozenQaStateDigest(base), capturedAt: "" };
134
+ }
135
+ async function captureOnceAsync(cwd, progress) {
136
+ const [headBytes, status, combinedDiff, stagedDiff, unstagedDiff, stagedNames, unstagedNames, untrackedNames] = await Promise.all([
137
+ runGit(cwd, ["rev-parse", "HEAD"]), runGit(cwd, ["status", "--porcelain=v2", "-z", "--untracked-files=all", ...PRODUCT_PATHSPEC]),
138
+ runGit(cwd, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]), runGit(cwd, ["diff", "--cached", "--binary", ...PRODUCT_PATHSPEC]),
139
+ runGit(cwd, ["diff", "--binary", ...PRODUCT_PATHSPEC]), runGit(cwd, ["diff", "--cached", "--name-status", "-z", ...PRODUCT_PATHSPEC]),
140
+ runGit(cwd, ["diff", "--name-status", "-z", ...PRODUCT_PATHSPEC]), runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]),
141
+ ]);
142
+ const paths = untrackedPathsFrom(untrackedNames);
143
+ const untracked = [];
144
+ for (let index = 0; index < paths.length; index++) {
145
+ progress("freezing Builder source state", `reading untracked file ${index + 1}/${paths.length}`);
146
+ untracked.push(await captureUntrackedAsync(cwd, paths[index]));
157
147
  }
158
- else
159
- cpSync(from, to, { preserveTimestamps: true });
160
- }
161
- async function copyUntrackedAsync(source, target, path) {
162
- const from = join(source, path);
163
- const to = join(target, path);
164
- await mkdir(dirname(to), { recursive: true });
165
- const stat = await lstat(from);
166
- if (stat.isSymbolicLink()) {
167
- await rm(to, { force: true });
168
- await symlink(await readlink(from), to);
148
+ const base = { head: headBytes.toString().trim(), status, combinedDiff, stagedDiff, unstagedDiff, changeSummary: renderSummary(stagedNames, unstagedNames, untrackedNames), untracked };
149
+ return { ...base, digest: calculateFrozenQaStateDigest(base), capturedAt: "" };
150
+ }
151
+ export function calculateFrozenQaStateDigest(state) {
152
+ const h = createHash("sha256");
153
+ for (const [label, value] of [["head", Buffer.from(state.head)], ["status", state.status], ["combined", state.combinedDiff], ["staged", state.stagedDiff], ["unstaged", state.unstagedDiff], ["summary", Buffer.from(state.changeSummary)]]) {
154
+ h.update(label).update("\0").update(value).update("\0");
169
155
  }
170
- else {
171
- await cp(from, to, { preserveTimestamps: true });
156
+ for (const item of state.untracked)
157
+ h.update(item.path).update("\0").update(item.kind).update("\0").update(String(item.mode)).update("\0").update(item.bytes).update("\0");
158
+ return h.digest("hex");
159
+ }
160
+ function manifestFromFrozen(state) {
161
+ return { diffDigest: hash(state.combinedDiff), untracked: state.untracked.map(({ path, kind, mode, digest }) => ({ path, kind, mode, digest })) };
162
+ }
163
+ function applyFrozenSync(review, state) {
164
+ applyPatchSync(review, state.stagedDiff, true);
165
+ applyPatchSync(review, state.unstagedDiff, false);
166
+ for (const item of state.untracked)
167
+ writeCapturedUntrackedSync(review, item);
168
+ }
169
+ async function applyFrozenAsync(review, state, progress) {
170
+ progress("preparing disposable QA snapshot", "applying captured staged changes");
171
+ await applyPatchAsync(review, state.stagedDiff, true);
172
+ progress("preparing disposable QA snapshot", "applying captured unstaged changes");
173
+ await applyPatchAsync(review, state.unstagedDiff, false);
174
+ for (let index = 0; index < state.untracked.length; index++) {
175
+ progress("preparing disposable QA snapshot", `materializing captured untracked files ${index + 1}/${state.untracked.length}`);
176
+ await writeCapturedUntrackedAsync(review, state.untracked[index]);
172
177
  }
173
178
  }
174
- function runGit(cwd, args, input) {
175
- return new Promise((resolvePromise, reject) => {
176
- const child = spawn("git", ["-C", cwd, ...args], { stdio: ["pipe", "pipe", "pipe"] });
177
- const stdout = [];
178
- const stderr = [];
179
- child.stdout.on("data", (chunk) => stdout.push(chunk));
180
- child.stderr.on("data", (chunk) => stderr.push(chunk));
181
- child.on("error", reject);
182
- child.on("close", (code) => {
183
- if (code === 0)
184
- resolvePromise(Buffer.concat(stdout));
185
- else
186
- reject(new Error(Buffer.concat(stderr).toString().trim() || `git exited with status ${code ?? "unknown"}`));
187
- });
188
- child.stdin.end(input);
189
- });
179
+ function applyPatchSync(review, patch, index) {
180
+ if (!patch.length)
181
+ return;
182
+ const applied = spawnSync("git", ["-C", review, "apply", ...(index ? ["--index"] : []), "--binary", "--whitespace=nowarn", "-"], { input: patch, encoding: "buffer", maxBuffer: 256 * 1024 * 1024 });
183
+ if (applied.status !== 0)
184
+ throw new Error(`cannot apply frozen Builder diff to QA worktree: ${Buffer.from(applied.stderr).toString().trim()}`);
185
+ }
186
+ async function applyPatchAsync(review, patch, index) {
187
+ if (patch.length)
188
+ await runGit(review, ["apply", ...(index ? ["--index"] : []), "--binary", "--whitespace=nowarn", "-"], patch);
189
+ }
190
+ function captureUntrackedSync(cwd, path) {
191
+ const absolute = join(cwd, path);
192
+ const stat = lstatSync(absolute);
193
+ if (!stat.isFile() && !stat.isSymbolicLink())
194
+ throw new Error(`unsupported untracked path type during QA capture: ${path}`);
195
+ const kind = stat.isSymbolicLink() ? "symlink" : "file";
196
+ const bytes = kind === "symlink" ? Buffer.from(readlinkSync(absolute)) : readFileSync(absolute);
197
+ return { path, kind, mode: stat.mode & 0o7777, digest: hash(bytes), bytes };
198
+ }
199
+ async function captureUntrackedAsync(cwd, path) {
200
+ const absolute = join(cwd, path);
201
+ const stat = await lstat(absolute);
202
+ if (!stat.isFile() && !stat.isSymbolicLink())
203
+ throw new Error(`unsupported untracked path type during QA capture: ${path}`);
204
+ const kind = stat.isSymbolicLink() ? "symlink" : "file";
205
+ const bytes = kind === "symlink" ? Buffer.from(await readlink(absolute)) : await readFile(absolute);
206
+ return { path, kind, mode: stat.mode & 0o7777, digest: hash(bytes), bytes };
207
+ }
208
+ function writeCapturedUntrackedSync(review, item) {
209
+ const target = join(review, item.path);
210
+ mkdirSync(dirname(target), { recursive: true });
211
+ if (item.kind === "symlink")
212
+ symlinkSync(item.bytes.toString(), target);
213
+ else
214
+ writeFileSync(target, item.bytes, { mode: item.mode });
215
+ }
216
+ async function writeCapturedUntrackedAsync(review, item) {
217
+ const target = join(review, item.path);
218
+ await mkdir(dirname(target), { recursive: true });
219
+ if (item.kind === "symlink")
220
+ await symlink(item.bytes.toString(), target);
221
+ else
222
+ await writeFile(target, item.bytes, { mode: item.mode });
223
+ }
224
+ function describeUntrackedSync(worktree, path) {
225
+ const item = captureUntrackedSync(worktree, path);
226
+ return { path: item.path, kind: item.kind, mode: item.mode, digest: item.digest };
227
+ }
228
+ function untrackedPaths(worktree) { return untrackedPathsFrom(gitBuffer(resolve(worktree), ["ls-files", "--others", "--exclude-standard", "-z"])); }
229
+ async function untrackedPathsAsync(worktree) { return untrackedPathsFrom(await runGit(worktree, ["ls-files", "--others", "--exclude-standard", "-z"])); }
230
+ function untrackedPathsFrom(raw) { return raw.toString().split("\0").filter((path) => path && isQaProductPath(path)).sort(); }
231
+ function isQaProductPath(path) { return path !== ".foreman" && !path.startsWith(".foreman/") && path !== ".rafi/cache" && !path.startsWith(".rafi/cache/"); }
232
+ function renderSummary(staged, unstaged, untracked) {
233
+ const normalize = (value) => value.toString().split("\0").filter(Boolean).filter(isQaProductPath).sort().join("\n") || "(none)";
234
+ return `tracked/staged:\n${normalize(staged)}\ntracked/unstaged:\n${normalize(unstaged)}\nuntracked:\n${normalize(untracked)}`;
190
235
  }
191
- function errorMessage(error) {
192
- return error instanceof Error ? error.message : String(error);
236
+ function assertSnapshotMatches(review, expected) {
237
+ const actual = changeManifest(review);
238
+ if (JSON.stringify(actual) !== JSON.stringify(expected))
239
+ throw new Error(`disposable QA snapshot does not match frozen source manifest: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
240
+ }
241
+ async function assertSnapshotMatchesAsync(review, expected, progress) {
242
+ const actual = await changeManifestAsync(review, progress, "verifying frozen QA snapshot");
243
+ if (JSON.stringify(actual) !== JSON.stringify(expected))
244
+ throw new Error(`disposable QA snapshot does not match frozen source manifest: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
193
245
  }
194
246
  function manifestDifference(before, after) {
195
247
  const changes = [];
@@ -202,3 +254,26 @@ function manifestDifference(before, after) {
202
254
  changes.push(path);
203
255
  return changes;
204
256
  }
257
+ function addWorktreeSync(root, review, head) {
258
+ const added = spawnSync("git", ["-C", root, "worktree", "add", "--detach", review, head], { encoding: "utf8" });
259
+ if (added.status !== 0) {
260
+ rmSync(dirname(review), { recursive: true, force: true });
261
+ throw new Error(`cannot create disposable QA worktree: ${added.stderr.trim()}`);
262
+ }
263
+ }
264
+ function removeWorktreeSync(root, review, tempRoot) { spawnSync("git", ["-C", root, "worktree", "remove", "--force", review], { encoding: "utf8" }); rmSync(tempRoot, { recursive: true, force: true }); }
265
+ function gitText(cwd, args) { return gitBuffer(cwd, args).toString().trim(); }
266
+ function gitBuffer(cwd, args) { return execFileSync("git", ["-C", cwd, ...args], { encoding: "buffer", maxBuffer: 256 * 1024 * 1024 }); }
267
+ function runGit(cwd, args, input) {
268
+ return new Promise((resolvePromise, reject) => {
269
+ const child = spawn("git", ["-C", cwd, ...args], { stdio: ["pipe", "pipe", "pipe"] });
270
+ const stdout = [];
271
+ const stderr = [];
272
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
273
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
274
+ child.on("error", reject);
275
+ child.on("close", (code) => code === 0 ? resolvePromise(Buffer.concat(stdout)) : reject(new Error(Buffer.concat(stderr).toString().trim() || `git exited with status ${code ?? "unknown"}`)));
276
+ child.stdin.end(input);
277
+ });
278
+ }
279
+ function hash(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
@@ -0,0 +1,37 @@
1
+ import type { AutonomyConfig, AutonomyProfile, InterruptionDomain, PendingHumanDecision, RecoveryDisposition, ResolvedAutonomyPolicy, StructuredInterruption, WorkflowIssueCode } from "rafi-spec";
2
+ import { WorkflowDb } from "./workflowDb.js";
3
+ export declare const DEFAULT_AUTONOMY_CONFIG: AutonomyConfig;
4
+ export declare function loadProjectAutonomyConfig(projectDir: string): AutonomyConfig | undefined;
5
+ /** QA precedence: CLI -> frozen run -> canonical project config -> legacy foreman.yaml. */
6
+ export declare function resolveQaEnablement(input: {
7
+ cli?: boolean;
8
+ frozen?: boolean;
9
+ projectDir: string;
10
+ legacyForeman?: boolean;
11
+ }): {
12
+ enabled: boolean;
13
+ source: "cli" | "frozen" | "project" | "legacy-foreman" | "default";
14
+ deprecationWarning?: string;
15
+ };
16
+ export declare function validateAutonomyConfig(value: unknown): AutonomyConfig;
17
+ export declare function resolveAutonomyPolicy(config: Partial<AutonomyConfig> | undefined, profileOverride?: AutonomyProfile, now?: Date): ResolvedAutonomyPolicy;
18
+ export declare function mapWorkflowIssueDomain(code: WorkflowIssueCode): InterruptionDomain;
19
+ export declare function evaluateRecovery(policy: ResolvedAutonomyPolicy, interruption: StructuredInterruption, attemptsUsed: number): RecoveryDisposition;
20
+ export declare function qaDecisionChoices(): PendingHumanDecision["choices"];
21
+ export declare class RecoveryDispatcher {
22
+ private readonly db;
23
+ private readonly policy;
24
+ constructor(db: WorkflowDb, policy: ResolvedAutonomyPolicy);
25
+ dispatch(input: {
26
+ interruption: StructuredInterruption;
27
+ reconcile?: () => Promise<"confirmed" | "absent" | "uncertain">;
28
+ execute: (disposition: RecoveryDisposition) => Promise<{
29
+ ok: boolean;
30
+ detail?: string;
31
+ }>;
32
+ }): Promise<{
33
+ disposition: RecoveryDisposition;
34
+ decision?: PendingHumanDecision;
35
+ attemptId?: string;
36
+ }>;
37
+ }