ai-foreman 1.7.4 → 1.7.5

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/qaReview.js CHANGED
@@ -1,9 +1,10 @@
1
+ import { currentActivity, withActivityPhase } from "./activity.js";
1
2
  import { buildQaFixInstruction, buildQaInstruction, parseStepStatus } from "./foreman.js";
2
- import { createDisposableQaSnapshot } from "./qaSnapshot.js";
3
+ import { createDisposableQaSnapshotAsync } from "./qaSnapshot.js";
3
4
  export async function runIsolatedQa(opts) {
4
5
  const history = [];
5
6
  for (let cycle = 1; cycle <= opts.maxCycles; cycle++) {
6
- const review = await oneReview(opts, cycle);
7
+ const review = await withActivityPhase(`running QA cycle ${cycle}/${opts.maxCycles}`, () => oneReview(opts, cycle));
7
8
  if (review.outcome === "retry-modification") {
8
9
  cycle -= 1;
9
10
  continue;
@@ -15,7 +16,7 @@ export async function runIsolatedQa(opts) {
15
16
  history.push({ cycle, outcome: "qa_fail", detail: review.detail });
16
17
  if (cycle === opts.maxCycles)
17
18
  break;
18
- const fix = await opts.fix(review.detail ?? "QA reported unspecified issues");
19
+ const fix = await withActivityPhase("applying QA fixes", () => opts.fix(review.detail ?? "QA reported unspecified issues"));
19
20
  if (!fix.ok)
20
21
  return { outcome: "blocked", detail: fix.detail ?? "Builder QA fix failed" };
21
22
  }
@@ -30,19 +31,20 @@ export async function runIsolatedQa(opts) {
30
31
  const issues = decision.action === "remediate" ? decision.remediation : history.at(-1)?.detail;
31
32
  if (!issues)
32
33
  return { outcome: "nonconverged", detail: "Planner remediation did not produce approved fix instructions" };
33
- const fix = await opts.fix(issues);
34
+ const fix = await withActivityPhase("applying QA remediation", () => opts.fix(issues));
34
35
  if (!fix.ok)
35
36
  return { outcome: "blocked", detail: fix.detail ?? "Builder QA fix failed" };
36
37
  return runIsolatedQa(opts);
37
38
  }
38
39
  async function oneReview(opts, cycle) {
39
- const snapshot = createDisposableQaSnapshot(opts.builderWorktree);
40
+ const progress = (state, detail) => currentActivity()?.update(state, detail);
41
+ const snapshot = await withActivityPhase("preparing disposable QA snapshot", () => createDisposableQaSnapshotAsync(opts.builderWorktree, progress));
40
42
  let qa;
41
43
  try {
42
44
  const resume = opts.qaStrategy === "compact" && opts.state.modificationViolations === 0 ? opts.state.sessionId : undefined;
43
45
  qa = await opts.createQa(snapshot.path, resume);
44
46
  if (opts.qaStrategy === "compact" && opts.state.reviews > 0 && resume) {
45
- const compacted = await compactWithRetry(qa);
47
+ const compacted = await withActivityPhase("compacting QA session", () => compactWithRetry(qa));
46
48
  if (!compacted.ok) {
47
49
  await qa.close();
48
50
  qa = await opts.createQa(snapshot.path);
@@ -59,7 +61,7 @@ async function oneReview(opts, cycle) {
59
61
  const status = parseStepStatus(turn.text);
60
62
  opts.state.reviews += 1;
61
63
  opts.state.sessionId = qa.sessionId();
62
- const changes = snapshot.qaChanges();
64
+ const changes = await withActivityPhase("checking QA file changes", () => snapshot.qaChanges());
63
65
  if (changes.length) {
64
66
  opts.state.modificationViolations += 1;
65
67
  opts.evidence?.({ cycle, outcome: "qa_file_modification", detail: "QA modified the disposable review copy", qaDiff: changes });
@@ -82,8 +84,9 @@ async function oneReview(opts, cycle) {
82
84
  return { outcome: "needs-human", detail: status.error ?? `QA returned ${status.kind}` };
83
85
  }
84
86
  finally {
85
- await qa?.close().catch(() => { });
86
- snapshot.remove();
87
+ if (qa)
88
+ await withActivityPhase("closing QA session", () => qa.close().catch(() => { }));
89
+ await withActivityPhase("cleaning up disposable QA snapshot", () => snapshot.remove());
87
90
  }
88
91
  }
89
92
  export async function compactWithRetry(adapter) {
@@ -14,6 +14,20 @@ export interface DisposableQaSnapshot {
14
14
  qaChanges(): string[];
15
15
  remove(): void;
16
16
  }
17
+ export interface AsyncDisposableQaSnapshot {
18
+ path: string;
19
+ manifest: QaChangeManifest;
20
+ verify(): Promise<void>;
21
+ qaChanges(): Promise<string[]>;
22
+ remove(): Promise<void>;
23
+ }
24
+ export type QaSnapshotProgress = (state: string, detail?: string) => void;
17
25
  /** Reproduce the Builder's exact change set in a detached disposable worktree. */
18
26
  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
+ export declare function createDisposableQaSnapshotAsync(builderWorktree: string, progress?: QaSnapshotProgress): Promise<AsyncDisposableQaSnapshot>;
19
32
  export declare function changeManifest(worktree: string): QaChangeManifest;
33
+ export declare function changeManifestAsync(worktree: string, progress?: QaSnapshotProgress, state?: string): Promise<QaChangeManifest>;
@@ -1,8 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
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";
3
4
  import { tmpdir } from "node:os";
4
5
  import { dirname, join, resolve } from "node:path";
5
- import { execFileSync, spawnSync } from "node:child_process";
6
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
6
7
  /** Reproduce the Builder's exact change set in a detached disposable worktree. */
7
8
  export function createDisposableQaSnapshot(builderWorktree) {
8
9
  const source = resolve(builderWorktree);
@@ -47,6 +48,69 @@ export function createDisposableQaSnapshot(builderWorktree) {
47
48
  throw error;
48
49
  }
49
50
  }
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
+ export async function createDisposableQaSnapshotAsync(builderWorktree, progress = () => { }) {
56
+ const source = resolve(builderWorktree);
57
+ progress("preparing disposable QA snapshot", "locating Builder worktree");
58
+ const root = (await runGit(source, ["rev-parse", "--show-toplevel"])).toString().trim();
59
+ const tempRoot = await mkdtemp(join(tmpdir(), "rafi-qa-"));
60
+ 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
+ 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");
88
+ 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
+ },
97
+ qaChanges: async () => manifestDifference(manifest, await changeManifestAsync(review, progress)),
98
+ remove: async () => {
99
+ progress("cleaning up disposable QA snapshot", "removing detached review worktree");
100
+ await runGit(root, ["worktree", "remove", "--force", review]).catch(() => { });
101
+ await rm(tempRoot, { recursive: true, force: true });
102
+ },
103
+ };
104
+ progress("preparing disposable QA snapshot", "verifying review copy");
105
+ await snapshot.verify();
106
+ return snapshot;
107
+ }
108
+ catch (error) {
109
+ await runGit(root, ["worktree", "remove", "--force", review]).catch(() => { });
110
+ await rm(tempRoot, { recursive: true, force: true });
111
+ throw error;
112
+ }
113
+ }
50
114
  export function changeManifest(worktree) {
51
115
  const diff = execFileSync("git", ["-C", worktree, "diff", "--binary", "HEAD"], { encoding: "buffer", maxBuffer: 128 * 1024 * 1024 });
52
116
  const untracked = untrackedPaths(worktree).map((path) => {
@@ -58,10 +122,29 @@ export function changeManifest(worktree) {
58
122
  });
59
123
  return { diffDigest: createHash("sha256").update(diff).digest("hex"), untracked };
60
124
  }
125
+ export async function changeManifestAsync(worktree, progress = () => { }, state = "checking QA file changes") {
126
+ const diff = await runGit(worktree, ["diff", "--binary", "HEAD"]);
127
+ const paths = await untrackedPathsAsync(worktree);
128
+ const untracked = [];
129
+ for (let index = 0; index < paths.length; index++) {
130
+ const path = paths[index];
131
+ progress(state, `hashing untracked files ${index + 1}/${paths.length}`);
132
+ const absolute = join(worktree, path);
133
+ const stat = await lstat(absolute);
134
+ const kind = stat.isSymbolicLink() ? "symlink" : "file";
135
+ 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") });
137
+ }
138
+ return { diffDigest: createHash("sha256").update(diff).digest("hex"), untracked };
139
+ }
61
140
  function untrackedPaths(worktree) {
62
141
  const raw = execFileSync("git", ["-C", worktree, "ls-files", "--others", "--exclude-standard", "-z"], { encoding: "buffer" });
63
142
  return raw.toString().split("\0").filter(Boolean).sort();
64
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
+ }
65
148
  function copyUntracked(source, target, path) {
66
149
  const from = join(source, path);
67
150
  const to = join(target, path);
@@ -75,6 +158,39 @@ function copyUntracked(source, target, path) {
75
158
  else
76
159
  cpSync(from, to, { preserveTimestamps: true });
77
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);
169
+ }
170
+ else {
171
+ await cp(from, to, { preserveTimestamps: true });
172
+ }
173
+ }
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
+ });
190
+ }
191
+ function errorMessage(error) {
192
+ return error instanceof Error ? error.message : String(error);
193
+ }
78
194
  function manifestDifference(before, after) {
79
195
  const changes = [];
80
196
  if (before.diffDigest !== after.diffDigest)
@@ -1,6 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { accessSync, constants, statSync } from "node:fs";
3
3
  import { delimiter, isAbsolute, resolve } from "node:path";
4
+ import { currentActivity, withActivityPhase } from "./activity.js";
4
5
  export const RUNTIME_PROBE_TIMEOUT_MS = 30_000;
5
6
  export const RUNTIME_DIAGNOSTIC_LIMIT = 8 * 1024;
6
7
  const RELEVANT_ENV = /^(ANTHROPIC|CLAUDE|CODEX|OPENAI|HTTP_PROXY|HTTPS_PROXY|NO_PROXY|SSL_CERT_FILE|NODE_EXTRA_CA_CERTS)(_|$)/i;
@@ -52,6 +53,9 @@ function isRunnableFile(path, platform) {
52
53
  }
53
54
  }
54
55
  export async function probeRuntime(cwd, runtime, opts = {}) {
56
+ return withActivityPhase(`checking ${runtime} runtime`, () => probeRuntimeInternal(cwd, runtime, opts));
57
+ }
58
+ async function probeRuntimeInternal(cwd, runtime, opts) {
55
59
  const env = opts.env ?? process.env;
56
60
  const command = runtime === "claude" ? "claude" : "codex";
57
61
  const executable = resolveExecutablePath(command, env) ?? command;
@@ -68,6 +72,7 @@ export async function probeRuntime(cwd, runtime, opts = {}) {
68
72
  const spawnOpts = { cwd, env, stdio: ["ignore", "pipe", "pipe"] };
69
73
  const child = spawn(executable, args, spawnOpts);
70
74
  const append = (chunk) => {
75
+ currentActivity()?.pulse(`${runtime} runtime responded`);
71
76
  if (output.length >= limit)
72
77
  return;
73
78
  const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
@@ -1,10 +1,13 @@
1
- import { execFileSync } from "node:child_process";
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { promisify } from "node:util";
2
3
  import { createHash } from "node:crypto";
3
4
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
4
5
  import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
5
6
  import { parse, stringify } from "yaml";
6
7
  import { fetchAndSnapshotUrl, normalizePublicHttpUrl, snapshotExternalLocalFile } from "../tickets/sourceFetch.js";
7
8
  import { fetchJiraIssues, fetchLinearIssues } from "../tickets/importer.js";
9
+ import { withActivityPhase } from "../activity.js";
10
+ const execFileAsync = promisify(execFile);
8
11
  export const SOURCE_REQUEST_START = "RAFI_SOURCE_REQUEST_START";
9
12
  export const SOURCE_REQUEST_END = "RAFI_SOURCE_REQUEST_END";
10
13
  const CONFIG_FILE = "rafi-config.yaml";
@@ -148,9 +151,12 @@ export function sourceRequestFromAnswer(answer, projectDir) {
148
151
  if (/^https?:\/\/\S+$/i.test(value))
149
152
  return requestFromUrl(value);
150
153
  const candidate = resolve(projectDir, value);
151
- if (value && existsSync(candidate))
152
- return { type: "local", label: basename(candidate), locator: { path: normalizeLocalPath(projectDir, candidate) } };
153
- if (hasGlobMagic(value) || /^(?:\.{0,2}\/|\/)/.test(value) || /[\\/][^\\/]+\.[A-Za-z0-9]{1,12}$/.test(value))
154
+ if (value && existsSync(candidate)) {
155
+ const rel = relative(resolve(projectDir), candidate);
156
+ const path = !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel) ? rel || "." : candidate;
157
+ return { type: "local", label: basename(candidate), locator: { path } };
158
+ }
159
+ if (hasGlobMagic(value) || /^(?:\.{0,2}\/|\/)/.test(value) || /^[^\s]+[\\/][^\\/]+\.[A-Za-z0-9]{1,12}$/.test(value))
154
160
  return { type: "local", label: basename(value), locator: { path: value } };
155
161
  if (/^(?:open\s+)?issues\s+for\s+(?:this|the)\s+repo(?:sitory)?$/i.test(value))
156
162
  return { type: inferOriginProvider(projectDir), label: "Open repository issues", locator: { repository: inferOriginRepository(projectDir), mode: "issues", filters: { state: "open" } } };
@@ -272,7 +278,7 @@ async function captureUrl(projectDir, entry, storage) {
272
278
  rmSync(resolve(projectDir, fetched.metadataPath), { force: true });
273
279
  return version;
274
280
  }
275
- function captureCliProvider(projectDir, entry, storage) {
281
+ async function captureCliProvider(projectDir, entry, storage) {
276
282
  const repository = entry.locator.repository || inferOriginRepository(projectDir);
277
283
  const mode = entry.locator.mode ?? "issues";
278
284
  let command;
@@ -309,7 +315,8 @@ function captureCliProvider(projectDir, entry, storage) {
309
315
  }
310
316
  let text;
311
317
  try {
312
- text = execFileSync(command, args, { cwd: projectDir, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 });
318
+ const result = await withActivityPhase(`fetching ${entry.type} source`, () => execFileAsync(command, args, { cwd: projectDir, encoding: "utf8", maxBuffer: 20 * 1024 * 1024 }));
319
+ text = result.stdout;
313
320
  }
314
321
  catch (error) {
315
322
  throw new Error(`${entry.type} source ${entry.label} could not be retrieved with authenticated ${command}: ${error instanceof Error ? error.message : String(error)}`);
@@ -5,6 +5,7 @@ import { StateDb } from "./stateDb.js";
5
5
  import { nowTimestamp } from "./events.js";
6
6
  import { loadTicketsConfig, resolveTicketPaths } from "./config.js";
7
7
  import { insertXlSplitRecommendations } from "./recommendations.js";
8
+ import { currentActivity, withActivityPhase } from "../activity.js";
8
9
  export function importFromMarkdown(_progressDocPath) {
9
10
  throw new Error("foreman tickets import is not yet implemented.\n\n" +
10
11
  "To start fresh:\n" +
@@ -19,9 +20,9 @@ export function importFromMarkdown(_progressDocPath) {
19
20
  export async function importExternalSources(projectDir, sources, opts) {
20
21
  const results = [];
21
22
  for (const source of sources) {
22
- const items = source.type === "linear"
23
- ? await fetchLinearIssues(source, opts)
24
- : await fetchJiraIssues(source, opts);
23
+ const items = await withActivityPhase(`importing ${source.type} tickets`, () => source.type === "linear"
24
+ ? fetchLinearIssues(source, opts)
25
+ : fetchJiraIssues(source, opts));
25
26
  const snapshotPath = writeImportSnapshot(projectDir, source.type, {
26
27
  source: redactSource(source),
27
28
  fetched_at: new Date().toISOString(),
@@ -42,11 +43,13 @@ export async function importExternalSources(projectDir, sources, opts) {
42
43
  return results;
43
44
  }
44
45
  export async function validateExternalSourceAccess(source) {
45
- if (source.type === "linear") {
46
- await fetchLinearIssues(source, { importCap: 1, commentLimit: 0 });
47
- return;
48
- }
49
- await fetchJiraIssues(source, { importCap: 1, commentLimit: 0 });
46
+ await withActivityPhase(`checking ${source.type} access`, async () => {
47
+ if (source.type === "linear") {
48
+ await fetchLinearIssues(source, { importCap: 1, commentLimit: 0 });
49
+ return;
50
+ }
51
+ await fetchJiraIssues(source, { importCap: 1, commentLimit: 0 });
52
+ });
50
53
  }
51
54
  export async function fetchLinearIssues(source, opts) {
52
55
  const apiKey = process.env[source.api_key_env];
@@ -84,6 +87,7 @@ export async function fetchLinearIssues(source, opts) {
84
87
  if (items.length >= opts.importCap)
85
88
  break;
86
89
  }
90
+ currentActivity()?.update("importing Linear tickets", `fetched ${items.length}`);
87
91
  if (!issues?.pageInfo?.hasNextPage || !issues.pageInfo.endCursor || nodes.length === 0)
88
92
  break;
89
93
  after = issues.pageInfo.endCursor;
@@ -137,6 +141,7 @@ export async function fetchJiraIssues(source, opts) {
137
141
  if (items.length >= opts.importCap)
138
142
  break;
139
143
  }
144
+ currentActivity()?.update("importing Jira tickets", `fetched ${items.length}`);
140
145
  nextPageToken = typeof json.nextPageToken === "string" ? json.nextPageToken : undefined;
141
146
  if (!nextPageToken || issues.length === 0 || json.isLast === true)
142
147
  break;
@@ -5,12 +5,22 @@ import { isIP } from "node:net";
5
5
  import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { readdirSync } from "node:fs";
7
7
  import { parse as parseHtml } from "parse5";
8
+ import { currentActivity, withActivityPhase } from "../activity.js";
8
9
  export const DEFAULT_SOURCE_FETCH_LIMITS = {
9
10
  timeoutMs: 15_000,
10
11
  maxBytes: 10 * 1024 * 1024,
11
12
  maxRedirects: 5,
12
13
  };
13
14
  export async function fetchAndSnapshotUrl(projectDir, input, limits = {}) {
15
+ const label = (() => { try {
16
+ return `fetching source ${new URL(input).hostname}`;
17
+ }
18
+ catch {
19
+ return "fetching URL source";
20
+ } })();
21
+ return withActivityPhase(label, () => fetchAndSnapshotUrlInternal(projectDir, input, limits));
22
+ }
23
+ async function fetchAndSnapshotUrlInternal(projectDir, input, limits) {
14
24
  const effective = { ...DEFAULT_SOURCE_FETCH_LIMITS, ...limits };
15
25
  const requested = normalizePublicHttpUrl(input);
16
26
  let current = requested;
@@ -126,6 +136,7 @@ async function readLimitedBody(response, maxBytes) {
126
136
  if (done)
127
137
  break;
128
138
  total += value.length;
139
+ currentActivity()?.pulse(`downloaded ${formatBytes(total)}`);
129
140
  if (total > maxBytes) {
130
141
  await reader.cancel();
131
142
  throw new Error(`URL content exceeds ${maxBytes} byte limit`);
@@ -174,6 +185,7 @@ async function extractSourceText(kind, bytes) {
174
185
  const document = await pdfjs.getDocument({ data: bytes, useWorkerFetch: false, isEvalSupported: false }).promise;
175
186
  const pages = [];
176
187
  for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber++) {
188
+ currentActivity()?.update("extracting PDF source", `page ${pageNumber}/${document.numPages}`);
177
189
  const page = await document.getPage(pageNumber);
178
190
  const content = await page.getTextContent();
179
191
  pages.push(content.items.map((item) => "str" in item ? item.str : "").join(" "));
@@ -185,6 +197,13 @@ async function extractSourceText(kind, bytes) {
185
197
  const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
186
198
  return kind === "html" ? htmlToText(decoded) : decoded;
187
199
  }
200
+ function formatBytes(bytes) {
201
+ if (bytes < 1024)
202
+ return `${bytes} B`;
203
+ if (bytes < 1024 * 1024)
204
+ return `${(bytes / 1024).toFixed(1)} KiB`;
205
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
206
+ }
188
207
  export function htmlToText(html) {
189
208
  const doc = parseHtml(html);
190
209
  const blocks = new Set(["address", "article", "aside", "blockquote", "br", "div", "footer", "h1", "h2", "h3", "h4", "h5", "h6", "header", "li", "main", "p", "section", "table", "tr"]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-foreman",
3
- "version": "1.7.4",
3
+ "version": "1.7.5",
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",
@@ -17,6 +17,10 @@
17
17
  "ai-foreman": "dist/index.js"
18
18
  },
19
19
  "exports": {
20
+ "./activity.js": {
21
+ "types": "./dist/activity.d.ts",
22
+ "default": "./dist/activity.js"
23
+ },
20
24
  "./agent-run.js": {
21
25
  "types": "./dist/agentRun.d.ts",
22
26
  "default": "./dist/agentRun.js"
@@ -25,6 +29,10 @@
25
29
  "types": "./dist/interviews.d.ts",
26
30
  "default": "./dist/interviews.js"
27
31
  },
32
+ "./provider-questions.js": {
33
+ "types": "./dist/providerQuestions.d.ts",
34
+ "default": "./dist/providerQuestions.js"
35
+ },
28
36
  "./runtime-readiness.js": {
29
37
  "types": "./dist/runtimeReadiness.d.ts",
30
38
  "default": "./dist/runtimeReadiness.js"
@@ -118,8 +126,8 @@
118
126
  "@anthropic-ai/claude-agent-sdk": "^0.3.148"
119
127
  },
120
128
  "dependencies": {
121
- "special-agents": "0.8.2",
122
- "rafi-spec": "0.8.2",
129
+ "special-agents": "0.8.3",
130
+ "rafi-spec": "0.8.3",
123
131
  "@clack/prompts": "^1.4.0",
124
132
  "ajv": "^8.20.0",
125
133
  "ajv-formats": "^3.0.1",