ai-foreman 1.7.13 → 1.7.14

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 (47) hide show
  1. package/README.md +33 -3
  2. package/dist/adapters/claude.d.ts +5 -0
  3. package/dist/adapters/claude.js +122 -16
  4. package/dist/adapters/codex.d.ts +2 -0
  5. package/dist/adapters/codex.js +30 -8
  6. package/dist/adapters/recovering.d.ts +2 -0
  7. package/dist/adapters/recovering.js +6 -0
  8. package/dist/adapters/types.d.ts +22 -0
  9. package/dist/agentRun.js +21 -3
  10. package/dist/branch/currentGuard.d.ts +2 -0
  11. package/dist/branch/currentGuard.js +9 -0
  12. package/dist/branch/finalization.d.ts +22 -0
  13. package/dist/branch/finalization.js +120 -0
  14. package/dist/branch/git.js +7 -2
  15. package/dist/branch/runner.d.ts +3 -2
  16. package/dist/branch/runner.js +277 -75
  17. package/dist/buildRuns.d.ts +1 -0
  18. package/dist/buildRuns.js +33 -3
  19. package/dist/cli/start.js +314 -86
  20. package/dist/cli/state.d.ts +4 -0
  21. package/dist/cli/state.js +69 -0
  22. package/dist/continuity.d.ts +4 -0
  23. package/dist/continuity.js +26 -3
  24. package/dist/foreman.d.ts +22 -4
  25. package/dist/foreman.js +105 -19
  26. package/dist/handoffs.d.ts +5 -2
  27. package/dist/handoffs.js +93 -12
  28. package/dist/index.js +2 -0
  29. package/dist/qaFailureDelivery.d.ts +100 -0
  30. package/dist/qaFailureDelivery.js +570 -0
  31. package/dist/qaProtocolV2.d.ts +233 -0
  32. package/dist/qaProtocolV2.js +114 -0
  33. package/dist/qaRecovery.d.ts +15 -3
  34. package/dist/qaRecovery.js +477 -83
  35. package/dist/qaReview.d.ts +42 -17
  36. package/dist/qaReview.js +1216 -457
  37. package/dist/qaRuntime.d.ts +14 -0
  38. package/dist/qaRuntime.js +44 -0
  39. package/dist/qaSnapshot.d.ts +34 -0
  40. package/dist/qaSnapshot.js +302 -34
  41. package/dist/stateTransfer.d.ts +57 -0
  42. package/dist/stateTransfer.js +644 -0
  43. package/dist/statusReporter.d.ts +2 -0
  44. package/dist/statusReporter.js +6 -0
  45. package/dist/workflowDb.d.ts +245 -2
  46. package/dist/workflowDb.js +647 -6
  47. package/package.json +15 -3
@@ -0,0 +1,14 @@
1
+ import type { ResolvedAgentSettings } from "rafi-spec";
2
+ import type { BuilderAdapter } from "./adapters/types.js";
3
+ import { type HandoffAcceptanceReceiptV2 } from "./qaProtocolV2.js";
4
+ import type { QaSessionBoundaryResult, QaSessionHandle } from "./qaReview.js";
5
+ /** Captured once, from the same inputs used to construct the provider adapter. */
6
+ export interface QaRuntimeMetadata {
7
+ settings: ResolvedAgentSettings;
8
+ effectiveRoleInstructions: string;
9
+ skills: QaSessionHandle["skills"];
10
+ }
11
+ export declare function frozenQaRuntimeSettings(adapter: BuilderAdapter, metadata: QaRuntimeMetadata | undefined): ResolvedAgentSettings;
12
+ export declare function describeQaRuntimeHandle(adapter: BuilderAdapter, metadata: QaRuntimeMetadata | undefined, handoffReceipt: QaSessionHandle["handoffReceipt"]): QaSessionHandle;
13
+ /** Construct and retain the receipt against the actual accepted successor. */
14
+ export declare function acceptQaRuntimeHandoff(handle: QaSessionHandle, createReceipt: (confinementDigest: string) => HandoffAcceptanceReceiptV2): QaSessionBoundaryResult;
@@ -0,0 +1,44 @@
1
+ import { readOnlyPermissionConfig } from "./agentRun.js";
2
+ import { qaDigest } from "./qaProtocolV2.js";
3
+ export function frozenQaRuntimeSettings(adapter, metadata) {
4
+ if (!metadata)
5
+ throw new Error("QA adapter has no frozen runtime/role/skill dispatch receipt");
6
+ if (metadata.settings.role !== "qa" || metadata.settings.make !== adapter.agent)
7
+ throw new Error("QA adapter does not match its frozen runtime settings");
8
+ return structuredClone(metadata.settings);
9
+ }
10
+ export function describeQaRuntimeHandle(adapter, metadata, handoffReceipt) {
11
+ const settings = frozenQaRuntimeSettings(adapter, metadata);
12
+ const confinementPolicy = { sandboxMode: "read-only", settingsSources: "none", permissions: readOnlyPermissionConfig(), dependencyProjection: "read-only-symlink", scratch: "sibling-temp-directory" };
13
+ const confinementBase = {
14
+ version: 2, sourceMode: "read-only", scratchMode: "isolated",
15
+ settingsSources: "none", networkMode: "provider-required",
16
+ environmentDigest: qaDigest("qa-confinement-environment", { runtime: settings.make, model: settings.model, reasoning: settings.reasoning, fast: settings.fast, platform: process.platform, arch: process.arch }),
17
+ policyDigest: qaDigest("qa-confinement-policy", confinementPolicy),
18
+ };
19
+ return {
20
+ adapter,
21
+ sessionIdentity: () => {
22
+ const ref = adapter.sessionRef?.();
23
+ if (!ref)
24
+ throw new Error("QA session has no scoped provider identity");
25
+ return ref;
26
+ },
27
+ effectiveRoleInstructions: metadata.effectiveRoleInstructions,
28
+ runtimeContext: settings,
29
+ skills: structuredClone(metadata.skills),
30
+ confinement: { ...confinementBase, digest: qaDigest("qa-confinement", confinementBase) },
31
+ handoffReceipt,
32
+ };
33
+ }
34
+ /** Construct and retain the receipt against the actual accepted successor. */
35
+ export function acceptQaRuntimeHandoff(handle, createReceipt) {
36
+ const receipt = createReceipt(handle.confinement.digest);
37
+ const successor = handle.sessionIdentity();
38
+ if (receipt.confinementDigest !== handle.confinement.digest || receipt.successor.provider !== successor.provider
39
+ || receipt.successor.sessionId !== successor.sessionId || receipt.successor.generation !== successor.generation
40
+ || receipt.successor.cwd !== successor.cwd || receipt.successor.configRoot !== successor.configRoot) {
41
+ throw new Error("QA acceptance receipt does not match the actual successor handle");
42
+ }
43
+ return { handle: { ...handle, handoffReceipt: { kind: "accepted", receipt } }, receipt };
44
+ }
@@ -5,14 +5,42 @@ export interface QaUntrackedCapture {
5
5
  digest: string;
6
6
  bytes: Buffer;
7
7
  }
8
+ export interface QaSourcePathState {
9
+ path: string;
10
+ staged: string[];
11
+ unstaged: string[];
12
+ headObject?: string;
13
+ indexObject?: string;
14
+ worktreeObject?: string;
15
+ untracked?: {
16
+ kind: "file" | "symlink";
17
+ mode: number;
18
+ digest: string;
19
+ };
20
+ }
8
21
  /** Immutable host-observable product state from which QA is constructed. */
9
22
  export interface FrozenQaSourceState {
10
23
  head: string;
24
+ /** Repository identity/config/ref/index metadata, separate from product bytes. */
25
+ originDigest: string;
26
+ /** Product content identity, including staging distinctions and untracked bytes. */
27
+ contentDigest: string;
28
+ repository: {
29
+ topLevel: string;
30
+ gitDir: string;
31
+ commonDir: string;
32
+ indexDigest: string;
33
+ configDigest: string;
34
+ refsDigest: string;
35
+ sparseDigest: string;
36
+ submoduleDigest: string;
37
+ };
11
38
  status: Buffer;
12
39
  combinedDiff: Buffer;
13
40
  stagedDiff: Buffer;
14
41
  unstagedDiff: Buffer;
15
42
  changeSummary: string;
43
+ pathInventory: QaSourcePathState[];
16
44
  untracked: QaUntrackedCapture[];
17
45
  digest: string;
18
46
  capturedAt: string;
@@ -50,10 +78,16 @@ export declare class QaSourceInstabilityError extends Error {
50
78
  /** Capture twice and accept only a byte-identical source state. */
51
79
  export declare function captureFrozenQaSource(worktree: string): FrozenQaSourceState;
52
80
  export declare function captureFrozenQaSourceAsync(worktree: string, progress?: QaSnapshotProgress): Promise<FrozenQaSourceState>;
81
+ export declare function captureStableFrozenQaSource(read: (attempt: number, pass: 1 | 2) => FrozenQaSourceState, now?: Date): FrozenQaSourceState;
82
+ export declare function captureStableFrozenQaSourceAsync(read: (attempt: number, pass: 1 | 2) => Promise<FrozenQaSourceState>, now?: Date): Promise<FrozenQaSourceState>;
53
83
  export declare function createDisposableQaSnapshot(builderWorktree: string): DisposableQaSnapshot;
54
84
  export declare function createDisposableQaSnapshotAsync(builderWorktree: string, progress?: QaSnapshotProgress): Promise<AsyncDisposableQaSnapshot>;
55
85
  export declare function frozenQaStateManifest(state: FrozenQaSourceState): QaChangeManifest;
56
86
  export declare function changeManifest(worktree: string): QaChangeManifest;
57
87
  export declare function changeManifestAsync(worktree: string, progress?: QaSnapshotProgress, state?: string): Promise<QaChangeManifest>;
58
88
  export declare function deterministicChangeSummaryAsync(worktree: string): Promise<string>;
89
+ /** Git tree for exactly what `git add -A && git commit` would publish. */
90
+ export declare function captureProspectiveGitTree(worktree: string): string;
91
+ /** Compare prospective publication trees while permitting only named host-owned paths. */
92
+ export declare function prospectiveGitTreeMatches(worktree: string, expectedTree: string, allowedPaths?: string[]): boolean;
59
93
  export declare function calculateFrozenQaStateDigest(state: Omit<FrozenQaSourceState, "digest" | "capturedAt">): string;
@@ -1,10 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { execFileSync, spawn, spawnSync } from "node:child_process";
3
- import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
4
  import { lstat, mkdir, mkdtemp, readFile, readlink, rm, symlink, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import { dirname, join, resolve } from "node:path";
7
- const PRODUCT_PATHSPEC = ["--", ".", ":(exclude).foreman/**", ":(exclude).rafi/cache/**"];
7
+ const PRODUCT_PATHSPEC = ["--", ".", ":(exclude).foreman/**", ":(exclude).rafi/**"];
8
8
  const MAX_CAPTURE_ATTEMPTS = 3; // initial attempt plus two bounded retries
9
9
  export class QaSourceInstabilityError extends Error {
10
10
  attempts;
@@ -17,22 +17,31 @@ export class QaSourceInstabilityError extends Error {
17
17
  /** Capture twice and accept only a byte-identical source state. */
18
18
  export function captureFrozenQaSource(worktree) {
19
19
  const cwd = resolve(worktree);
20
+ return captureStableFrozenQaSource(() => captureOnce(cwd));
21
+ }
22
+ export async function captureFrozenQaSourceAsync(worktree, progress = () => { }) {
23
+ const cwd = resolve(worktree);
24
+ return captureStableFrozenQaSourceAsync(async (attempt, pass) => {
25
+ if (pass === 1)
26
+ progress("freezing Builder source state", `integrity pass ${attempt}/3`);
27
+ return captureOnceAsync(cwd, progress);
28
+ });
29
+ }
30
+ export function captureStableFrozenQaSource(read, now = new Date()) {
20
31
  for (let attempt = 1; attempt <= MAX_CAPTURE_ATTEMPTS; attempt++) {
21
- const first = captureOnce(cwd);
22
- const second = captureOnce(cwd);
32
+ const first = read(attempt, 1);
33
+ const second = read(attempt, 2);
23
34
  if (first.digest === second.digest)
24
- return { ...first, capturedAt: new Date().toISOString() };
35
+ return { ...first, capturedAt: now.toISOString() };
25
36
  }
26
37
  throw new QaSourceInstabilityError(MAX_CAPTURE_ATTEMPTS);
27
38
  }
28
- export async function captureFrozenQaSourceAsync(worktree, progress = () => { }) {
29
- const cwd = resolve(worktree);
39
+ export async function captureStableFrozenQaSourceAsync(read, now = new Date()) {
30
40
  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);
41
+ const first = await read(attempt, 1);
42
+ const second = await read(attempt, 2);
34
43
  if (first.digest === second.digest)
35
- return { ...first, capturedAt: new Date().toISOString() };
44
+ return { ...first, capturedAt: now.toISOString() };
36
45
  }
37
46
  throw new QaSourceInstabilityError(MAX_CAPTURE_ATTEMPTS);
38
47
  }
@@ -42,21 +51,23 @@ export function createDisposableQaSnapshot(builderWorktree) {
42
51
  const frozenState = captureFrozenQaSource(source);
43
52
  const tempRoot = mkdtempSync(join(tmpdir(), "rafi-qa-"));
44
53
  const review = join(tempRoot, "review");
45
- addWorktreeSync(root, review, frozenState.head);
54
+ cloneRepositorySync(root, review, frozenState.head);
46
55
  try {
47
56
  applyFrozenSync(review, frozenState);
57
+ projectDependencyTrees(root, review);
58
+ mkdirSync(join(tempRoot, "scratch"), { mode: 0o700 });
48
59
  const manifest = manifestFromFrozen(frozenState);
49
60
  const snapshot = {
50
61
  path: review, manifest, frozenState,
51
62
  verify: () => assertSnapshotMatches(review, manifest),
52
63
  qaChanges: () => manifestDifference(manifest, changeManifest(review)),
53
- remove: () => removeWorktreeSync(root, review, tempRoot),
64
+ remove: () => rmSync(tempRoot, { recursive: true, force: true }),
54
65
  };
55
66
  snapshot.verify();
56
67
  return snapshot;
57
68
  }
58
69
  catch (error) {
59
- removeWorktreeSync(root, review, tempRoot);
70
+ rmSync(tempRoot, { recursive: true, force: true });
60
71
  throw error;
61
72
  }
62
73
  }
@@ -68,17 +79,18 @@ export async function createDisposableQaSnapshotAsync(builderWorktree, progress
68
79
  const tempRoot = await mkdtemp(join(tmpdir(), "rafi-qa-"));
69
80
  const review = join(tempRoot, "review");
70
81
  try {
71
- progress("preparing disposable QA snapshot", "creating detached review worktree");
72
- await runGit(root, ["worktree", "add", "--detach", review, frozenState.head]);
82
+ progress("preparing disposable QA snapshot", "creating independent review repository");
83
+ await cloneRepositoryAsync(root, review, frozenState.head);
73
84
  await applyFrozenAsync(review, frozenState, progress);
85
+ projectDependencyTrees(root, review);
86
+ await mkdir(join(tempRoot, "scratch"), { mode: 0o700 });
74
87
  const manifest = manifestFromFrozen(frozenState);
75
88
  const snapshot = {
76
89
  path: review, manifest, frozenState,
77
90
  verify: async () => assertSnapshotMatchesAsync(review, manifest, progress),
78
91
  qaChanges: async () => manifestDifference(manifest, await changeManifestAsync(review, progress)),
79
92
  remove: async () => {
80
- progress("cleaning up disposable QA snapshot", "removing detached review worktree");
81
- await runGit(root, ["worktree", "remove", "--force", review]).catch(() => { });
93
+ progress("cleaning up disposable QA snapshot", "removing independent review repository");
82
94
  await rm(tempRoot, { recursive: true, force: true });
83
95
  },
84
96
  };
@@ -87,7 +99,6 @@ export async function createDisposableQaSnapshotAsync(builderWorktree, progress
87
99
  return snapshot;
88
100
  }
89
101
  catch (error) {
90
- await runGit(root, ["worktree", "remove", "--force", review]).catch(() => { });
91
102
  await rm(tempRoot, { recursive: true, force: true });
92
103
  throw error;
93
104
  }
@@ -119,8 +130,39 @@ export async function deterministicChangeSummaryAsync(worktree) {
119
130
  ]);
120
131
  return renderSummary(staged, unstaged, untracked);
121
132
  }
133
+ /** Git tree for exactly what `git add -A && git commit` would publish. */
134
+ export function captureProspectiveGitTree(worktree) {
135
+ const cwd = resolve(worktree);
136
+ const temporary = mkdtempSync(join(tmpdir(), "rafi-qa-finalize-"));
137
+ const index = join(temporary, "index");
138
+ const env = { ...process.env, GIT_INDEX_FILE: index };
139
+ try {
140
+ execFileSync("git", ["read-tree", "HEAD"], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
141
+ execFileSync("git", ["add", "-A", "--", "."], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
142
+ return execFileSync("git", ["write-tree"], { cwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
143
+ }
144
+ finally {
145
+ rmSync(temporary, { recursive: true, force: true });
146
+ }
147
+ }
148
+ /** Compare prospective publication trees while permitting only named host-owned paths. */
149
+ export function prospectiveGitTreeMatches(worktree, expectedTree, allowedPaths = []) {
150
+ const currentTree = captureProspectiveGitTree(worktree);
151
+ if (currentTree === expectedTree)
152
+ return true;
153
+ const pathspec = ["--", ".", ...allowedPaths.map((path) => `:(exclude)${path.replaceAll("\\", "/")}`)];
154
+ const result = spawnSync("git", ["diff", "--quiet", expectedTree, currentTree, ...pathspec], { cwd: resolve(worktree), stdio: "ignore" });
155
+ if (result.error)
156
+ throw result.error;
157
+ if (result.status === 0)
158
+ return true;
159
+ if (result.status === 1)
160
+ return false;
161
+ throw new Error(`git diff could not compare QA finalization trees (exit ${result.status ?? "unknown"})`);
162
+ }
122
163
  function captureOnce(cwd) {
123
164
  const head = gitText(cwd, ["rev-parse", "HEAD"]);
165
+ const repository = captureRepositoryMetadataSync(cwd);
124
166
  const status = gitBuffer(cwd, ["status", "--porcelain=v2", "-z", "--untracked-files=all", ...PRODUCT_PATHSPEC]);
125
167
  const combinedDiff = gitBuffer(cwd, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
126
168
  const stagedDiff = gitBuffer(cwd, ["diff", "--cached", "--binary", ...PRODUCT_PATHSPEC]);
@@ -129,34 +171,66 @@ function captureOnce(cwd) {
129
171
  const unstagedNames = gitBuffer(cwd, ["diff", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
130
172
  const untrackedNames = gitBuffer(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]);
131
173
  const untracked = untrackedPathsFrom(untrackedNames).map((path) => captureUntrackedSync(cwd, path));
132
- const base = { head, status, combinedDiff, stagedDiff, unstagedDiff, changeSummary: renderSummary(stagedNames, unstagedNames, untrackedNames), untracked };
174
+ const pathInventory = enrichTrackedPathInventorySync(cwd, buildPathInventory(stagedNames, unstagedNames, untracked));
175
+ const content = { head, status, combinedDiff, stagedDiff, unstagedDiff, changeSummary: renderSummary(stagedNames, unstagedNames, untrackedNames), pathInventory, untracked };
176
+ const contentDigest = calculateFrozenQaContentDigest(content);
177
+ const base = { ...content, repository, originDigest: repositoryDigest(repository), contentDigest };
133
178
  return { ...base, digest: calculateFrozenQaStateDigest(base), capturedAt: "" };
134
179
  }
135
180
  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
- ]);
181
+ // These reads are deliberately serialized. Concurrent git reads can each
182
+ // observe a different index/worktree generation and manufacture a state
183
+ // that never existed. The enclosing double-capture supplies the stability
184
+ // fence across the complete sequence.
185
+ const headBytes = await runGit(cwd, ["rev-parse", "HEAD"]);
186
+ const repository = await captureRepositoryMetadataAsync(cwd);
187
+ const status = await runGit(cwd, ["status", "--porcelain=v2", "-z", "--untracked-files=all", ...PRODUCT_PATHSPEC]);
188
+ const combinedDiff = await runGit(cwd, ["diff", "--binary", "HEAD", ...PRODUCT_PATHSPEC]);
189
+ const stagedDiff = await runGit(cwd, ["diff", "--cached", "--binary", ...PRODUCT_PATHSPEC]);
190
+ const unstagedDiff = await runGit(cwd, ["diff", "--binary", ...PRODUCT_PATHSPEC]);
191
+ const stagedNames = await runGit(cwd, ["diff", "--cached", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
192
+ const unstagedNames = await runGit(cwd, ["diff", "--name-status", "-z", ...PRODUCT_PATHSPEC]);
193
+ const untrackedNames = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z"]);
142
194
  const paths = untrackedPathsFrom(untrackedNames);
143
195
  const untracked = [];
144
196
  for (let index = 0; index < paths.length; index++) {
145
197
  progress("freezing Builder source state", `reading untracked file ${index + 1}/${paths.length}`);
146
198
  untracked.push(await captureUntrackedAsync(cwd, paths[index]));
147
199
  }
148
- const base = { head: headBytes.toString().trim(), status, combinedDiff, stagedDiff, unstagedDiff, changeSummary: renderSummary(stagedNames, unstagedNames, untrackedNames), untracked };
200
+ const pathInventory = await enrichTrackedPathInventoryAsync(cwd, buildPathInventory(stagedNames, unstagedNames, untracked));
201
+ const content = { head: headBytes.toString().trim(), status, combinedDiff, stagedDiff, unstagedDiff, changeSummary: renderSummary(stagedNames, unstagedNames, untrackedNames), pathInventory, untracked };
202
+ const contentDigest = calculateFrozenQaContentDigest(content);
203
+ const base = { ...content, repository, originDigest: repositoryDigest(repository), contentDigest };
149
204
  return { ...base, digest: calculateFrozenQaStateDigest(base), capturedAt: "" };
150
205
  }
151
206
  export function calculateFrozenQaStateDigest(state) {
152
207
  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)]]) {
208
+ h.update("rafi.qa.source-state.v2\0").update(state.originDigest).update("\0").update(state.contentDigest).update("\0");
209
+ const pathBytes = Buffer.from(JSON.stringify(state.pathInventory.map((item) => ({
210
+ path: item.path, staged: item.staged, unstaged: item.unstaged,
211
+ ...(item.headObject ? { headObject: item.headObject } : {}),
212
+ ...(item.indexObject ? { indexObject: item.indexObject } : {}),
213
+ ...(item.worktreeObject ? { worktreeObject: item.worktreeObject } : {}),
214
+ ...(item.untracked ? { untracked: { kind: item.untracked.kind, mode: item.untracked.mode, digest: item.untracked.digest } } : {}),
215
+ }))));
216
+ 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)], ["paths", pathBytes]]) {
154
217
  h.update(label).update("\0").update(value).update("\0");
155
218
  }
156
219
  for (const item of state.untracked)
157
220
  h.update(item.path).update("\0").update(item.kind).update("\0").update(String(item.mode)).update("\0").update(item.bytes).update("\0");
158
221
  return h.digest("hex");
159
222
  }
223
+ function calculateFrozenQaContentDigest(state) {
224
+ const h = createHash("sha256").update("rafi.qa.content.v2\0");
225
+ h.update(state.head).update("\0").update(state.status).update("\0").update(state.combinedDiff).update("\0").update(state.stagedDiff).update("\0").update(state.unstagedDiff).update("\0");
226
+ h.update(JSON.stringify(state.pathInventory)).update("\0");
227
+ for (const item of state.untracked)
228
+ h.update(item.path).update("\0").update(item.kind).update("\0").update(String(item.mode)).update("\0").update(item.bytes).update("\0");
229
+ return h.digest("hex");
230
+ }
231
+ function repositoryDigest(repository) {
232
+ return createHash("sha256").update("rafi.qa.origin.v2\0").update(JSON.stringify(repository)).digest("hex");
233
+ }
160
234
  function manifestFromFrozen(state) {
161
235
  return { diffDigest: hash(state.combinedDiff), untracked: state.untracked.map(({ path, kind, mode, digest }) => ({ path, kind, mode, digest })) };
162
236
  }
@@ -228,11 +302,136 @@ function describeUntrackedSync(worktree, path) {
228
302
  function untrackedPaths(worktree) { return untrackedPathsFrom(gitBuffer(resolve(worktree), ["ls-files", "--others", "--exclude-standard", "-z"])); }
229
303
  async function untrackedPathsAsync(worktree) { return untrackedPathsFrom(await runGit(worktree, ["ls-files", "--others", "--exclude-standard", "-z"])); }
230
304
  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/"); }
305
+ function isQaProductPath(path) { return path !== ".foreman" && !path.startsWith(".foreman/") && path !== ".rafi" && !path.startsWith(".rafi/"); }
232
306
  function renderSummary(staged, unstaged, untracked) {
233
307
  const normalize = (value) => value.toString().split("\0").filter(Boolean).filter(isQaProductPath).sort().join("\n") || "(none)";
234
308
  return `tracked/staged:\n${normalize(staged)}\ntracked/unstaged:\n${normalize(unstaged)}\nuntracked:\n${normalize(untracked)}`;
235
309
  }
310
+ function buildPathInventory(staged, unstaged, untracked) {
311
+ const rows = new Map();
312
+ const row = (path) => {
313
+ let value = rows.get(path);
314
+ if (!value) {
315
+ value = { path, staged: [], unstaged: [] };
316
+ rows.set(path, value);
317
+ }
318
+ return value;
319
+ };
320
+ const addTracked = (raw, kind) => {
321
+ const tokens = raw.toString().split("\0").filter(Boolean);
322
+ for (let index = 0; index < tokens.length;) {
323
+ const status = tokens[index++];
324
+ const first = tokens[index++];
325
+ if (!first)
326
+ break;
327
+ if (/^[RC]/.test(status)) {
328
+ const second = tokens[index++];
329
+ if (!second)
330
+ break;
331
+ row(first)[kind].push(`${status}:from`);
332
+ row(second)[kind].push(`${status}:to`);
333
+ }
334
+ else
335
+ row(first)[kind].push(status);
336
+ }
337
+ };
338
+ addTracked(staged, "staged");
339
+ addTracked(unstaged, "unstaged");
340
+ for (const item of untracked)
341
+ row(item.path).untracked = { kind: item.kind, mode: item.mode, digest: item.digest };
342
+ return [...rows.values()].map((item) => ({ ...item, staged: item.staged.sort(), unstaged: item.unstaged.sort() })).sort((a, b) => a.path.localeCompare(b.path));
343
+ }
344
+ function enrichTrackedPathInventorySync(cwd, inventory) {
345
+ return inventory.map((item) => item.untracked ? item : {
346
+ ...item,
347
+ ...optionalIdentity("headObject", gitTreeIdentitySync(cwd, item.path)),
348
+ ...optionalIdentity("indexObject", gitIndexIdentitySync(cwd, item.path)),
349
+ ...optionalIdentity("worktreeObject", worktreeIdentitySync(cwd, item.path)),
350
+ });
351
+ }
352
+ async function enrichTrackedPathInventoryAsync(cwd, inventory) {
353
+ const result = [];
354
+ for (const item of inventory) {
355
+ if (item.untracked) {
356
+ result.push(item);
357
+ continue;
358
+ }
359
+ result.push({
360
+ ...item,
361
+ ...optionalIdentity("headObject", await gitTreeIdentityAsync(cwd, item.path)),
362
+ ...optionalIdentity("indexObject", await gitIndexIdentityAsync(cwd, item.path)),
363
+ ...optionalIdentity("worktreeObject", await worktreeIdentityAsync(cwd, item.path)),
364
+ });
365
+ }
366
+ return result;
367
+ }
368
+ function optionalIdentity(key, value) {
369
+ return value === undefined ? {} : { [key]: value };
370
+ }
371
+ function gitTreeIdentitySync(cwd, path) {
372
+ const result = spawnSync("git", ["-C", cwd, "ls-tree", "-z", "HEAD", "--", path], { encoding: "buffer" });
373
+ return result.status === 0 ? parseGitObjectIdentity(Buffer.from(result.stdout)) : undefined;
374
+ }
375
+ function gitIndexIdentitySync(cwd, path) {
376
+ const result = spawnSync("git", ["-C", cwd, "ls-files", "--stage", "-z", "--", path], { encoding: "buffer" });
377
+ return result.status === 0 ? parseGitObjectIdentity(Buffer.from(result.stdout), true) : undefined;
378
+ }
379
+ async function gitTreeIdentityAsync(cwd, path) {
380
+ try {
381
+ return parseGitObjectIdentity(await runGit(cwd, ["ls-tree", "-z", "HEAD", "--", path]));
382
+ }
383
+ catch {
384
+ return undefined;
385
+ }
386
+ }
387
+ async function gitIndexIdentityAsync(cwd, path) {
388
+ try {
389
+ return parseGitObjectIdentity(await runGit(cwd, ["ls-files", "--stage", "-z", "--", path]), true);
390
+ }
391
+ catch {
392
+ return undefined;
393
+ }
394
+ }
395
+ function parseGitObjectIdentity(value, index = false) {
396
+ const header = value.toString().split("\t", 1)[0]?.trim();
397
+ if (!header)
398
+ return undefined;
399
+ const parts = header.split(/\s+/);
400
+ const mode = parts[0];
401
+ const object = parts[index ? 1 : 2];
402
+ const stage = index ? parts[2] : undefined;
403
+ return mode && object && (!index || stage === "0") ? `${mode}:${object}` : undefined;
404
+ }
405
+ function worktreeIdentitySync(cwd, path) {
406
+ const absolute = join(cwd, path);
407
+ try {
408
+ const stat = lstatSync(absolute);
409
+ const mode = stat.mode & 0o7777;
410
+ if (stat.isSymbolicLink())
411
+ return `symlink:${mode}:${hash(Buffer.from(readlinkSync(absolute)))}`;
412
+ if (stat.isFile())
413
+ return `file:${mode}:${hash(readFileSync(absolute))}`;
414
+ return `other:${mode}`;
415
+ }
416
+ catch {
417
+ return undefined;
418
+ }
419
+ }
420
+ async function worktreeIdentityAsync(cwd, path) {
421
+ const absolute = join(cwd, path);
422
+ try {
423
+ const stat = await lstat(absolute);
424
+ const mode = stat.mode & 0o7777;
425
+ if (stat.isSymbolicLink())
426
+ return `symlink:${mode}:${hash(Buffer.from(await readlink(absolute)))}`;
427
+ if (stat.isFile())
428
+ return `file:${mode}:${hash(await readFile(absolute))}`;
429
+ return `other:${mode}`;
430
+ }
431
+ catch {
432
+ return undefined;
433
+ }
434
+ }
236
435
  function assertSnapshotMatches(review, expected) {
237
436
  const actual = changeManifest(review);
238
437
  if (JSON.stringify(actual) !== JSON.stringify(expected))
@@ -254,14 +453,83 @@ function manifestDifference(before, after) {
254
453
  changes.push(path);
255
454
  return changes;
256
455
  }
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) {
456
+ function cloneRepositorySync(root, review, head) {
457
+ const cloned = spawnSync("git", ["clone", "--no-local", "--no-hardlinks", "--no-checkout", "--quiet", root, review], { encoding: "utf8" });
458
+ if (cloned.status !== 0) {
260
459
  rmSync(dirname(review), { recursive: true, force: true });
261
- throw new Error(`cannot create disposable QA worktree: ${added.stderr.trim()}`);
460
+ throw new Error(`cannot create independent QA repository: ${cloned.stderr.trim()}`);
461
+ }
462
+ const checked = spawnSync("git", ["-C", review, "checkout", "--detach", "--quiet", head], { encoding: "utf8" });
463
+ if (checked.status !== 0) {
464
+ rmSync(dirname(review), { recursive: true, force: true });
465
+ throw new Error(`cannot check out frozen QA source: ${checked.stderr.trim()}`);
466
+ }
467
+ }
468
+ async function cloneRepositoryAsync(root, review, head) {
469
+ await runGit(dirname(review), ["clone", "--no-local", "--no-hardlinks", "--no-checkout", "--quiet", root, review]);
470
+ await runGit(review, ["checkout", "--detach", "--quiet", head]);
471
+ }
472
+ /**
473
+ * Dependency installs are execution environment, not reviewed product state.
474
+ * Project the existing trees without copying or permitting writes so validation
475
+ * commands in the independent clone can resolve the same installed toolchain.
476
+ */
477
+ function projectDependencyTrees(sourceRoot, reviewRoot) {
478
+ const parents = ["", ...readdirSync(sourceRoot, { withFileTypes: true })
479
+ .filter((entry) => entry.isDirectory() && entry.name !== ".git" && entry.name !== "node_modules")
480
+ .flatMap((entry) => {
481
+ const first = entry.name;
482
+ const nested = readdirSync(join(sourceRoot, first), { withFileTypes: true })
483
+ .filter((child) => child.isDirectory() && child.name !== "node_modules")
484
+ .map((child) => join(first, child.name));
485
+ return [first, ...nested];
486
+ })];
487
+ for (const parent of parents) {
488
+ const source = join(sourceRoot, parent, "node_modules");
489
+ const target = join(reviewRoot, parent, "node_modules");
490
+ if (!existsSync(source) || existsSync(target) || !lstatSync(source).isDirectory())
491
+ continue;
492
+ const relativeTarget = parent ? `${parent.replaceAll("\\", "/")}/node_modules` : "node_modules";
493
+ if (spawnSync("git", ["-C", reviewRoot, "check-ignore", "-q", "--", relativeTarget]).status !== 0)
494
+ continue;
495
+ mkdirSync(dirname(target), { recursive: true });
496
+ symlinkSync(source, target, "dir");
262
497
  }
263
498
  }
264
- function removeWorktreeSync(root, review, tempRoot) { spawnSync("git", ["-C", root, "worktree", "remove", "--force", review], { encoding: "utf8" }); rmSync(tempRoot, { recursive: true, force: true }); }
499
+ function captureRepositoryMetadataSync(cwd) {
500
+ const topLevel = gitText(cwd, ["rev-parse", "--show-toplevel"]);
501
+ const gitDir = gitText(cwd, ["rev-parse", "--path-format=absolute", "--git-dir"]);
502
+ const commonDir = gitText(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
503
+ return {
504
+ topLevel, gitDir, commonDir,
505
+ indexDigest: hash(gitBuffer(cwd, ["ls-files", "--stage", "-z"])),
506
+ configDigest: hash(gitBuffer(cwd, ["config", "--local", "--null", "--list"])),
507
+ refsDigest: hash(gitBuffer(cwd, ["for-each-ref", "--format=%(refname)%00%(objectname)%00"])),
508
+ sparseDigest: hashOptionalFile(join(gitDir, "info", "sparse-checkout")),
509
+ submoduleDigest: hash(gitBuffer(cwd, ["submodule", "status", "--recursive"])),
510
+ };
511
+ }
512
+ async function captureRepositoryMetadataAsync(cwd) {
513
+ const topLevel = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).toString().trim();
514
+ const gitDir = (await runGit(cwd, ["rev-parse", "--path-format=absolute", "--git-dir"])).toString().trim();
515
+ const commonDir = (await runGit(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"])).toString().trim();
516
+ const index = await runGit(cwd, ["ls-files", "--stage", "-z"]);
517
+ const config = await runGit(cwd, ["config", "--local", "--null", "--list"]);
518
+ const refs = await runGit(cwd, ["for-each-ref", "--format=%(refname)%00%(objectname)%00"]);
519
+ const submodules = await runGit(cwd, ["submodule", "status", "--recursive"]);
520
+ let sparse = Buffer.alloc(0);
521
+ try {
522
+ sparse = await readFile(join(gitDir, "info", "sparse-checkout"));
523
+ }
524
+ catch { /* absent is canonical empty */ }
525
+ return { topLevel, gitDir, commonDir, indexDigest: hash(index), configDigest: hash(config), refsDigest: hash(refs), sparseDigest: hash(sparse), submoduleDigest: hash(submodules) };
526
+ }
527
+ function hashOptionalFile(path) { try {
528
+ return hash(readFileSync(path));
529
+ }
530
+ catch {
531
+ return hash(Buffer.alloc(0));
532
+ } }
265
533
  function gitText(cwd, args) { return gitBuffer(cwd, args).toString().trim(); }
266
534
  function gitBuffer(cwd, args) { return execFileSync("git", ["-C", cwd, ...args], { encoding: "buffer", maxBuffer: 256 * 1024 * 1024 }); }
267
535
  function runGit(cwd, args, input) {
@@ -0,0 +1,57 @@
1
+ export declare const STATE_BUNDLE_FORMAT = "rafi-state-bundle.v1";
2
+ export declare const STATE_MANIFEST_PATH = "rafi-state-manifest.v1.json";
3
+ export declare const STATE_LINEAGE_PATH = ".rafi/state-transfer.json";
4
+ export interface StateBundleManifestV1 {
5
+ format: typeof STATE_BUNDLE_FORMAT;
6
+ bundleId: string;
7
+ createdAt: string;
8
+ lineage?: StateBundleLineage;
9
+ source: {
10
+ root: string;
11
+ git: GitSummary;
12
+ fingerprint: string;
13
+ };
14
+ packages: {
15
+ aiForemanVersion: string;
16
+ };
17
+ compatibility: {
18
+ dbBackedTicketsRequired: true;
19
+ providerSessionsPortable: false;
20
+ diagnosticsIncluded: true;
21
+ };
22
+ files: Array<{
23
+ path: string;
24
+ kind: "file" | "sqlite";
25
+ size: number;
26
+ sha256: string;
27
+ }>;
28
+ }
29
+ export interface StateBundleLineage {
30
+ baseBundleId?: string;
31
+ baseFingerprint: string;
32
+ sourceCurrentFingerprint: string;
33
+ }
34
+ export interface GitSummary {
35
+ branch?: string;
36
+ head?: string;
37
+ dirty?: boolean;
38
+ available: boolean;
39
+ }
40
+ export interface StateInspectResult {
41
+ manifest: StateBundleManifestV1;
42
+ valid: boolean;
43
+ }
44
+ export declare function exportStateBundle(projectDir: string, outputFile: string, opts?: {
45
+ now?: Date;
46
+ packageVersion?: string;
47
+ }): Promise<StateBundleManifestV1>;
48
+ export declare function inspectStateBundle(bundleFile: string): StateInspectResult;
49
+ export declare function importStateBundle(projectDir: string, bundleFile: string, opts?: {
50
+ yes?: boolean;
51
+ now?: Date;
52
+ }): Promise<{
53
+ manifest: StateBundleManifestV1;
54
+ backupDir?: string;
55
+ }>;
56
+ export declare function fingerprintState(projectDir: string): string;
57
+ export declare function hasTransferableState(projectDir: string): boolean;