ai-foreman 1.7.11 → 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 (75) 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 +8 -2
  13. package/dist/branch/resume.js +11 -1
  14. package/dist/branch/runner.d.ts +12 -3
  15. package/dist/branch/runner.js +131 -67
  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 +446 -142
  25. package/dist/cli/status.js +13 -2
  26. package/dist/cli/tickets.js +27 -9
  27. package/dist/config.d.ts +4 -0
  28. package/dist/config.js +28 -1
  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 +26 -6
  33. package/dist/foreman.js +147 -104
  34. package/dist/handoffs.d.ts +35 -3
  35. package/dist/handoffs.js +155 -17
  36. package/dist/index.js +6 -0
  37. package/dist/log.d.ts +3 -3
  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/notify.d.ts +4 -0
  44. package/dist/notify.js +15 -0
  45. package/dist/observability.d.ts +248 -0
  46. package/dist/observability.js +1018 -0
  47. package/dist/planChanges.d.ts +4 -0
  48. package/dist/planChanges.js +16 -0
  49. package/dist/projectDiagnostics.d.ts +27 -0
  50. package/dist/projectDiagnostics.js +407 -0
  51. package/dist/qaRecovery.d.ts +140 -0
  52. package/dist/qaRecovery.js +595 -0
  53. package/dist/qaReview.d.ts +133 -14
  54. package/dist/qaReview.js +984 -59
  55. package/dist/qaSnapshot.d.ts +31 -5
  56. package/dist/qaSnapshot.js +209 -134
  57. package/dist/recoveryPolicy.d.ts +37 -0
  58. package/dist/recoveryPolicy.js +251 -0
  59. package/dist/roles.js +1 -0
  60. package/dist/supervisor.d.ts +40 -0
  61. package/dist/supervisor.js +102 -0
  62. package/dist/ticketHistoryReader.d.ts +19 -0
  63. package/dist/ticketHistoryReader.js +58 -0
  64. package/dist/tickets/details.d.ts +8 -0
  65. package/dist/tickets/details.js +59 -11
  66. package/dist/tickets/output.d.ts +5 -0
  67. package/dist/tickets/output.js +73 -0
  68. package/dist/tickets/stateDb.d.ts +1 -0
  69. package/dist/tickets/stateDb.js +5 -0
  70. package/dist/workflowDb.d.ts +31 -2
  71. package/dist/workflowDb.js +192 -23
  72. package/dist/workflowReader.d.ts +42 -0
  73. package/dist/workflowReader.js +171 -0
  74. package/foreman.yaml +13 -0
  75. package/package.json +51 -3
package/dist/buildRuns.js CHANGED
@@ -4,24 +4,31 @@ import { hostname } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import { execFileSync } from "node:child_process";
6
6
  import Database from "better-sqlite3";
7
- import { WorkflowDb } from "./workflowDb.js";
7
+ import { WorkflowDb, heartbeatCurrentWorkflowLease, readCurrentWorkflowLease } from "./workflowDb.js";
8
+ import { WorkflowReader } from "./workflowReader.js";
8
9
  import { canonicalSessionPath, captureWorkspaceIdentity, createProviderSessionRef, latestSessionBinding, upsertSessionBinding } from "./sessionIdentity.js";
9
10
  import { resolveProviderSessionAvailability } from "./sessionAvailability.js";
10
11
  import { captureCurrentWorkflowSessionIdentity, currentWorkflowIdentityKey } from "./branch/currentGuard.js";
11
12
  import { isTicketsInitialized, loadTicketsConfig, resolveTicketPaths } from "./tickets/config.js";
12
13
  import { loadTickets } from "./tickets/ticketLoader.js";
13
- export const BUILD_RUN_VERSION = 2;
14
+ import { loadProjectAutonomyConfig, resolveAutonomyPolicy } from "./recoveryPolicy.js";
15
+ export const BUILD_RUN_VERSION = 3;
14
16
  export const BUILD_RUN_DIRECTORY = ".foreman/runs";
15
17
  export const BUILD_HEARTBEAT_MS = 10_000;
16
18
  export const BUILD_LEASE_STALE_MS = 45_000;
17
19
  export function createBuildRun(input) {
18
20
  const now = input.now ?? new Date();
21
+ // Complete the one-time legacy import before the new mutable build snapshot
22
+ // exists, so a brand-new run is never mistaken for legacy input.
23
+ const migration = new WorkflowDb(input.repositoryRoot);
24
+ migration.close();
19
25
  const stamp = now.toISOString();
20
26
  const worktree = resolve(input.worktree ?? input.repositoryRoot);
21
27
  const snapshot = captureGitSnapshot(worktree, input);
28
+ const frozenPolicy = input.frozenPolicy ?? resolveAutonomyPolicy(loadProjectAutonomyConfig(input.repositoryRoot), input.autonomyProfile, now);
22
29
  const run = {
23
- version: 2,
24
- runId: randomUUID(),
30
+ version: 3,
31
+ runId: input.runId ?? randomUUID(),
25
32
  status: "running",
26
33
  tickets: [...input.tickets],
27
34
  deliveryUnit: input.deliveryUnit,
@@ -40,6 +47,13 @@ export function createBuildRun(input) {
40
47
  baselineComplete: Boolean(snapshot.baselineHead && snapshot.startHead && snapshot.branch),
41
48
  },
42
49
  progress: { completedTickets: [], completedOperations: [], remainingTickets: [...input.tickets], nextAction: input.tickets[0] ? `Start ${input.tickets[0]}` : "Plan next work" },
50
+ frozenPolicy,
51
+ phase: "created",
52
+ qaEnabled: input.qaEnabled ?? Boolean(input.qa),
53
+ recoveryAttempts: [],
54
+ supervisor: { status: frozenPolicy.supervisorEnabled ? "starting" : "disabled", generation: 0, workerGeneration: 0, checkpointRestarts: 0, runRestarts: 0 },
55
+ pendingDecisions: [],
56
+ deferredTickets: [],
43
57
  runDecisions: input.runDecisions,
44
58
  receipts: {},
45
59
  lease: currentLease(now),
@@ -95,18 +109,17 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
95
109
  validateBuildRun(run);
96
110
  const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
97
111
  mkdirSync(directory, { recursive: true });
98
- const completedOperations = Object.keys(run.receipts).sort();
99
- const next = {
100
- ...run,
112
+ const upgraded = upgradeBuildRun(run, projectDir, now);
113
+ const completedOperations = Object.keys(upgraded.receipts).sort();
114
+ let next = {
115
+ ...upgraded,
101
116
  progress: { ...run.progress, completedOperations, remainingTickets: remainingTickets(run) },
102
117
  updatedAt: now.toISOString(),
103
118
  };
104
- const target = join(directory, `${run.runId}.json`);
105
- const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
106
- writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
107
- renameSync(temp, target);
108
119
  const workflow = new WorkflowDb(projectDir);
109
120
  try {
121
+ const frozenPolicy = workflow.freezeAutonomyPolicy(next.runId, next.frozenPolicy, now);
122
+ next = { ...next, frozenPolicy, recoveryAttempts: workflow.recoveryAttempts(next.runId), pendingDecisions: workflow.pendingHumanDecisions(next.runId), supervisor: workflow.supervisorState(next.runId) ?? next.supervisor };
110
123
  const existing = workflow.getRun(next.runId);
111
124
  if (!existing)
112
125
  workflow.createRun({
@@ -127,6 +140,10 @@ export function saveBuildRun(projectDir, run, now = new Date()) {
127
140
  finally {
128
141
  workflow.close();
129
142
  }
143
+ const target = join(directory, `${run.runId}.json`);
144
+ const temp = `${target}.${process.pid}.${randomUUID()}.tmp`;
145
+ writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
146
+ renameSync(temp, target);
130
147
  return next;
131
148
  }
132
149
  export function checkpointBuildRun(projectDir, run, checkpoint, patch = {}) {
@@ -185,17 +202,11 @@ export function recordBuildReceipt(projectDir, run, operationId, detail) {
185
202
  return next;
186
203
  }
187
204
  export function heartbeatBuildRun(projectDir, run, now = new Date()) {
188
- const saved = saveBuildRun(projectDir, { ...run, lease: { ...(run.lease ?? currentLease(now)), heartbeatAt: now.toISOString() } }, now);
189
- const workflow = new WorkflowDb(projectDir);
190
- try {
191
- const lease = workflow.currentLease();
192
- if (lease?.runId === run.runId)
193
- workflow.heartbeatLease(lease, now);
194
- }
195
- finally {
196
- workflow.close();
197
- }
198
- return saved;
205
+ const lease = readCurrentWorkflowLease(projectDir);
206
+ if (!lease || lease.runId !== run.runId)
207
+ throw new Error("workflow lease ownership changed");
208
+ const next = heartbeatCurrentWorkflowLease(projectDir, lease, now);
209
+ return { ...run, lease: { hostname: next.host, pid: next.pid, processStart: next.processStart, heartbeatAt: next.heartbeatAt } };
199
210
  }
200
211
  export function releaseBuildLease(projectDir, run, status = "interrupted") {
201
212
  const saved = saveBuildRun(projectDir, { ...run, status, lease: undefined });
@@ -211,21 +222,41 @@ export function readBuildRuns(projectDir) {
211
222
  const directory = join(resolve(projectDir), BUILD_RUN_DIRECTORY);
212
223
  if (!existsSync(directory))
213
224
  return [];
214
- return readdirSync(directory).filter((name) => name.endsWith(".json")).flatMap((name) => {
225
+ const workflow = existsSync(join(resolve(projectDir), ".rafi", "recovery.sqlite3")) ? new WorkflowDb(projectDir) : undefined;
226
+ const runs = readdirSync(directory).filter((name) => name.endsWith(".json")).flatMap((name) => {
215
227
  try {
216
- const run = JSON.parse(readFileSync(join(directory, name), "utf8"));
217
- validateBuildRun(run);
218
- return [upgradeBuildRun(run)];
228
+ const projection = JSON.parse(readFileSync(join(directory, name), "utf8"));
229
+ validateBuildRun(projection);
230
+ const authoritative = workflow?.getRun(projection.runId)?.state;
231
+ const run = authoritative?.runId ? authoritative : projection;
232
+ const upgraded = upgradeBuildRun(run, projectDir);
233
+ return [{ ...upgraded, frozenPolicy: workflow?.autonomyPolicy(run.runId) ?? upgraded.frozenPolicy, recoveryAttempts: workflow?.recoveryAttempts(run.runId) ?? upgraded.recoveryAttempts, pendingDecisions: workflow?.pendingHumanDecisions(run.runId) ?? upgraded.pendingDecisions, supervisor: workflow?.supervisorState(run.runId) ?? upgraded.supervisor }];
219
234
  }
220
235
  catch {
221
236
  return [];
222
237
  }
223
238
  }).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
239
+ workflow?.close();
240
+ return runs;
224
241
  }
225
242
  export function recoverableBuildRuns(projectDir, now = new Date()) {
243
+ const databaseLease = readCurrentWorkflowLease(projectDir);
226
244
  return readBuildRuns(projectDir)
227
245
  .filter((run) => run.status !== "completed")
228
- .map((run) => ({ ...inferLegacyRunTickets(projectDir, run), active: isLeaseActive(run, now) }));
246
+ .map((run) => ({ ...inferLegacyRunTickets(projectDir, run), active: databaseLease?.runId === run.runId ? workflowLeaseLooksLive(databaseLease, now) : isLeaseActive(run, now) }));
247
+ }
248
+ function workflowLeaseLooksLive(lease, now) {
249
+ if (now.getTime() - new Date(lease.heartbeatAt).getTime() > BUILD_LEASE_STALE_MS)
250
+ return false;
251
+ if (lease.host !== hostname())
252
+ return true;
253
+ try {
254
+ process.kill(lease.pid, 0);
255
+ return lease.processStart === processStartIdentity(lease.pid);
256
+ }
257
+ catch {
258
+ return false;
259
+ }
229
260
  }
230
261
  /**
231
262
  * Current-branch runs created before ticket checkpointing did not retain their
@@ -298,7 +329,7 @@ export function projectBuildRecovery(projectDir, run, now = new Date(), ticketOv
298
329
  let issues = [];
299
330
  let workflowState = {};
300
331
  if (existsSync(join(resolve(projectDir), ".rafi", "recovery.sqlite3"))) {
301
- const workflow = new WorkflowDb(projectDir);
332
+ const workflow = new WorkflowReader(projectDir);
302
333
  try {
303
334
  operationNames = workflow.operations(run.runId).filter((operation) => operation.status === "confirmed").map((operation) => operation.kind);
304
335
  issues = workflow.issues(run.runId).map((issue) => issue.detail);
@@ -425,7 +456,7 @@ function processStartIdentity(pid) {
425
456
  }
426
457
  }
427
458
  function validateBuildRun(run) {
428
- if (![1, 2].includes(run.version) || !run.runId || !run.repository?.root || !run.repository.worktree || !run.createdAt || !run.updatedAt) {
459
+ if (![1, 2, 3].includes(run.version) || !run.runId || !run.repository?.root || !run.repository.worktree || !run.createdAt || !run.updatedAt) {
429
460
  throw new Error("invalid build run record");
430
461
  }
431
462
  }
@@ -497,14 +528,20 @@ function relativeTime(value, now) {
497
528
  return `${Math.floor(seconds / 3600)}h ago`;
498
529
  return `${Math.floor(seconds / 86_400)}d ago`;
499
530
  }
500
- function upgradeBuildRun(run) {
501
- if (run.version === 2)
531
+ function upgradeBuildRun(run, projectDir = run.repository.root, now = new Date()) {
532
+ if (run.version === 3)
502
533
  return run;
534
+ const policy = resolveAutonomyPolicy(loadProjectAutonomyConfig(projectDir), undefined, new Date(run.createdAt || now));
535
+ if (run.version === 2)
536
+ return {
537
+ ...run, version: 3, frozenPolicy: policy, phase: run.checkpoint, qaEnabled: Boolean(run.qa), recoveryAttempts: [],
538
+ supervisor: { status: policy.supervisorEnabled ? "stopped" : "disabled", generation: 0, workerGeneration: 0, checkpointRestarts: 0, runRestarts: 0 }, pendingDecisions: [], deferredTickets: [], legacy: true,
539
+ };
503
540
  const legacy = run;
504
541
  const remaining = legacy.status === "completed" ? [] : legacy.tickets.slice(Math.max(0, legacy.currentTicket ? legacy.tickets.indexOf(legacy.currentTicket) : 0));
505
542
  return {
506
543
  ...legacy,
507
- version: 2,
544
+ version: 3,
508
545
  legacy: true,
509
546
  repository: {
510
547
  ...legacy.repository,
@@ -524,6 +561,13 @@ function upgradeBuildRun(run) {
524
561
  lastSuccessfulAction: legacy.checkpoint,
525
562
  nextAction: remaining[0] ? `Resume ${remaining[0]}` : "Inspect legacy run",
526
563
  },
564
+ frozenPolicy: policy,
565
+ phase: legacy.checkpoint,
566
+ qaEnabled: Boolean(legacy.qa),
567
+ recoveryAttempts: [],
568
+ supervisor: { status: policy.supervisorEnabled ? "stopped" : "disabled", generation: 0, workerGeneration: 0, checkpointRestarts: 0, runRestarts: 0 },
569
+ pendingDecisions: [],
570
+ deferredTickets: [],
527
571
  };
528
572
  }
529
573
  function releaseWorkflowLease(projectDir, runId, now = new Date()) {
@@ -1,2 +1,9 @@
1
1
  import { Command } from "commander";
2
2
  export declare function buildDoctorCommand(): Command;
3
+ export declare function storageReport(projectDir: string): {
4
+ recoveryBytes: number;
5
+ observabilityBytes: number;
6
+ observabilityWalBytes: number;
7
+ logBytes: number;
8
+ summaryOnly: boolean;
9
+ };
@@ -1,6 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import { resolve, join } from "node:path";
3
- import { existsSync, readFileSync } from "node:fs";
3
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { loadConfig } from "../config.js";
6
6
  import { isTicketsInitialized } from "../tickets/config.js";
@@ -8,6 +8,9 @@ import { cmdValidate } from "../tickets/commands.js";
8
8
  import { checkGitHubReadiness, inspectGitHubRemote } from "../branch/github.js";
9
9
  import { requireClaudeSDK } from "../adapters/claude.js";
10
10
  import { resolveExecutablePath, sanitizeDiagnostics } from "../runtimeReadiness.js";
11
+ import { ObservabilityReader, ObservabilityStore } from "../observability.js";
12
+ import { WorkflowReader } from "../workflowReader.js";
13
+ import { WorkflowDb } from "../workflowDb.js";
11
14
  const PACKAGE_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))?.version;
12
15
  function commandVersion(command) {
13
16
  const result = spawnSync(command, ["--version"], {
@@ -26,8 +29,16 @@ export function buildDoctorCommand() {
26
29
  .argument("[project]", "path to the project directory", ".")
27
30
  .option("--github", "run GitHub PR readiness checks")
28
31
  .option("--live-claude", "run a bounded no-tools Claude adapter request (uses account quota)")
32
+ .option("--storage", "report recovery, observability, and JSONL storage without writing")
33
+ .option("--cleanup-storage", "prune expired observability detail and fully compact storage")
29
34
  .action(async (project, opts) => {
30
35
  const cwd = resolve(project);
36
+ if (opts.storage || opts.cleanupStorage) {
37
+ if (opts.cleanupStorage)
38
+ cleanupStorage(cwd);
39
+ reportStorage(cwd);
40
+ return;
41
+ }
31
42
  let errors = 0;
32
43
  const report = (ok, label, detail) => {
33
44
  console.log(`${ok ? "ok" : "!!"} ${label}${detail ? ` — ${detail}` : ""}`);
@@ -120,6 +131,78 @@ export function buildDoctorCommand() {
120
131
  process.exit(errors === 0 ? 0 : 1);
121
132
  });
122
133
  }
134
+ export function storageReport(projectDir) {
135
+ const root = resolve(projectDir);
136
+ const recovery = join(root, ".rafi", "recovery.sqlite3");
137
+ const reader = new ObservabilityReader(root);
138
+ try {
139
+ const storage = reader.storage();
140
+ return { recoveryBytes: fileBytes(recovery) + fileBytes(`${recovery}-wal`) + fileBytes(`${recovery}-shm`), observabilityBytes: storage.databaseBytes, observabilityWalBytes: storage.walBytes, logBytes: directoryLogBytes(join(root, ".foreman")), summaryOnly: storage.summaryOnly };
141
+ }
142
+ finally {
143
+ reader.close();
144
+ }
145
+ }
146
+ function reportStorage(projectDir) {
147
+ const report = storageReport(projectDir);
148
+ console.log(`storage recovery=${report.recoveryBytes} bytes observability=${report.observabilityBytes} bytes wal=${report.observabilityWalBytes} bytes logs=${report.logBytes} bytes summary_only=${report.summaryOnly}`);
149
+ }
150
+ function cleanupStorage(projectDir) {
151
+ const workflow = new WorkflowReader(projectDir);
152
+ try {
153
+ const lease = workflow.currentLease();
154
+ if (lease && Date.now() - new Date(lease.heartbeatAt).getTime() <= 45_000)
155
+ throw new Error(`refusing storage cleanup while live lease ${lease.runId} is active`);
156
+ }
157
+ finally {
158
+ workflow.close();
159
+ }
160
+ const before = storageReport(projectDir);
161
+ const config = loadConfig(join(projectDir, "foreman.yaml"));
162
+ const store = new ObservabilityStore(projectDir, { config: config.observability });
163
+ let rows = 0;
164
+ let recoveryRows = 0;
165
+ let logs = { compressed: 0, deleted: 0, reclaimedBytes: 0 };
166
+ try {
167
+ rows = store.enforceLimits().prunedRows;
168
+ logs = store.maintainLogs(projectDir);
169
+ store.compact();
170
+ }
171
+ finally {
172
+ store.close();
173
+ }
174
+ const recovery = new WorkflowDb(projectDir);
175
+ try {
176
+ recoveryRows = recovery.pruneTerminalTelemetry(config.observability.detail_retention_days);
177
+ recovery.compactStorage();
178
+ }
179
+ finally {
180
+ recovery.close();
181
+ }
182
+ const after = storageReport(projectDir);
183
+ const reclaimed = before.observabilityBytes + before.observabilityWalBytes + before.logBytes
184
+ - after.observabilityBytes - after.observabilityWalBytes - after.logBytes;
185
+ console.log(`storage cleanup reclaimed=${Math.max(0, reclaimed)} bytes pruned_rows=${rows} recovery_pruned_rows=${recoveryRows} compressed_logs=${logs.compressed} deleted_logs=${logs.deleted}`);
186
+ }
187
+ function fileBytes(path) { try {
188
+ return statSync(path).size;
189
+ }
190
+ catch {
191
+ return 0;
192
+ } }
193
+ function directoryLogBytes(path) {
194
+ if (!existsSync(path))
195
+ return 0;
196
+ let total = 0;
197
+ for (const entry of readdirSync(path, { withFileTypes: true })) {
198
+ const child = join(path, entry.name);
199
+ if (entry.isDirectory())
200
+ total += directoryLogBytes(child);
201
+ else if (entry.name.endsWith(".jsonl") || entry.name.endsWith(".jsonl.gz"))
202
+ total += fileBytes(child);
203
+ }
204
+ return total;
205
+ }
123
206
  async function liveClaudeSmoke(cwd, executable) {
124
207
  const abortController = new AbortController();
125
208
  const timer = setTimeout(() => abortController.abort(), 45_000);
@@ -0,0 +1,17 @@
1
+ import { Command } from "commander";
2
+ import type { EffortLevel } from "../adapters/types.js";
3
+ import type { ExternalDiagnosticMode } from "../diagnostics.js";
4
+ export interface ManagerCommandOptions {
5
+ resolveProject?: (project: string | undefined) => string;
6
+ requireProject?: boolean;
7
+ }
8
+ export declare function buildManagerCommand(options?: ManagerCommandOptions): Command;
9
+ export declare function runManager(projectDir: string, options: {
10
+ runId?: string;
11
+ ask?: string;
12
+ agent?: string;
13
+ model?: string;
14
+ effort?: EffortLevel;
15
+ fast?: boolean;
16
+ external?: ExternalDiagnosticMode;
17
+ }): Promise<void>;
@@ -0,0 +1,135 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createInterface } from "node:readline/promises";
3
+ import { stdin as input, stdout as output } from "node:process";
4
+ import { resolve } from "node:path";
5
+ import { Command } from "commander";
6
+ import { createRoleBuilder, readOnlyPermissionConfig } from "../agentRun.js";
7
+ import { Log } from "../log.js";
8
+ import { buildManagerEvidencePacket, buildManagerProjectPacket } from "../managerPacket.js";
9
+ import { ManagerSessionRecorder } from "../observability.js";
10
+ import { collectManagerProjectDiagnostics, executeManagerEvidenceRequest, MANAGER_LOOKUP_MAX_ROUNDS, parseManagerEvidenceRequest, resolveManagerQuestionRuns } from "../projectDiagnostics.js";
11
+ export function buildManagerCommand(options = {}) {
12
+ return new Command("manager")
13
+ .description("Ask a read-only Manager about all retained builds in a project.")
14
+ .argument(options.requireProject ? "<project>" : "[project]", "project directory")
15
+ .option("--run <run-id>", "set the initial focused build run")
16
+ .option("--ask <question>", "ask one question and exit")
17
+ .option("--agent <runtime>", "claude | codex")
18
+ .option("--model <model>", "provider model ID")
19
+ .option("--effort <level>", "low | medium | high | xhigh")
20
+ .option("--fast", "use the provider fast mode")
21
+ .option("--external <mode>", "auto | on | off", "auto")
22
+ .action(async (project, opts) => {
23
+ const root = options.resolveProject ? options.resolveProject(project) : resolve(project ?? ".");
24
+ await runManager(root, {
25
+ runId: stringOption(opts.run), ask: stringOption(opts.ask), agent: stringOption(opts.agent), model: stringOption(opts.model),
26
+ effort: stringOption(opts.effort), fast: Boolean(opts.fast), external: validateExternal(opts.external),
27
+ });
28
+ });
29
+ }
30
+ export async function runManager(projectDir, options) {
31
+ if (!options.ask && (!input.isTTY || !output.isTTY))
32
+ throw new Error("non-TTY Manager use requires --ask <question>");
33
+ const initialCollection = collectManagerProjectDiagnostics(projectDir, { initialFocusRunId: options.runId, question: options.ask, external: "off" });
34
+ const initialReport = initialCollection.report;
35
+ let currentFocusRunId = initialReport.initialFocusRunId;
36
+ let referencedRunIds = [currentFocusRunId];
37
+ output.write(`rafi manager: ${initialReport.totalRunCount} retained build run${initialReport.totalRunCount === 1 ? "" : "s"}\n`);
38
+ output.write(`rafi manager: verified active run ${initialReport.verifiedActiveRunId ?? "none"}\n`);
39
+ output.write(`rafi manager: initial focus ${currentFocusRunId}\n`);
40
+ if (initialReport.staleRecoveryRunIds.length)
41
+ output.write(`rafi manager: stale recovery state ${initialReport.staleRecoveryRunIds.join(", ")}\n`);
42
+ const metadata = new ManagerSessionRecorder(projectDir);
43
+ const managerSessionId = randomUUID();
44
+ const startedAt = new Date().toISOString();
45
+ metadata.record({ sessionId: managerSessionId, runId: initialReport.initialFocusRunId, startedAt, reportDigest: initialReport.digest, scope: "project", latestFocusRunId: currentFocusRunId, projectReportDigest: initialReport.projectDigest });
46
+ let role;
47
+ let packetState;
48
+ let usage;
49
+ let lookupRounds = 0;
50
+ let lookupOperations = 0;
51
+ try {
52
+ const activeRole = role = await createRoleBuilder({ projectDir, role: "manager", agent: options.agent, model: options.model, effort: options.effort, fast: options.fast,
53
+ yes: true, allowSwitch: false, label: "Manager", log: new Log(), permissionConfig: denyManagerTools(), sandboxMode: "read-only", persistSessionBindings: false });
54
+ const ask = async (question) => {
55
+ const refreshed = collectManagerProjectDiagnostics(projectDir, { initialFocusRunId: initialReport.initialFocusRunId, currentFocusRunId, referencedRunIds, question, external: "off" });
56
+ const resolved = resolveManagerQuestionRuns(refreshed.allSummaries, question, currentFocusRunId, referencedRunIds);
57
+ currentFocusRunId = resolved.focusRunId;
58
+ referencedRunIds = resolved.referencedRunIds.length ? resolved.referencedRunIds : referencedRunIds;
59
+ const collection = collectManagerProjectDiagnostics(projectDir, { initialFocusRunId: initialReport.initialFocusRunId, currentFocusRunId, referencedRunIds, question, external: options.external });
60
+ const report = collection.report;
61
+ const packet = buildManagerProjectPacket(report, question, packetState, referencedRunIds);
62
+ packetState = packet.state;
63
+ let result = await activeRole.builder.sendTurn(packet.prompt);
64
+ let questionLookupRounds = 0;
65
+ while (questionLookupRounds < MANAGER_LOOKUP_MAX_ROUNDS) {
66
+ const request = parseManagerEvidenceRequest(result.text);
67
+ if (!request) {
68
+ if (!looksLikeEvidenceRequest(result.text))
69
+ break;
70
+ questionLookupRounds += 1;
71
+ lookupRounds += 1;
72
+ result = await activeRole.builder.sendTurn(buildManagerEvidencePacket({ version: 1, requestId: "invalid-request", results: [{ kind: "list_runs", status: "invalid", limitation: "The evidence envelope was invalid. Use only the documented fixed fields and read-only operations, then answer with any remaining limitation disclosed." }], lookupRound: questionLookupRounds, remainingRounds: MANAGER_LOOKUP_MAX_ROUNDS - questionLookupRounds, digest: report.digest }, question));
73
+ continue;
74
+ }
75
+ questionLookupRounds += 1;
76
+ lookupRounds += 1;
77
+ lookupOperations += Math.min(6, request.operations.length);
78
+ const response = executeManagerEvidenceRequest(projectDir, request, collection, questionLookupRounds);
79
+ packetState.lastEvidenceScope = request.operations.flatMap(operation => "runIds" in operation ? operation.runIds : []);
80
+ result = await activeRole.builder.sendTurn(buildManagerEvidencePacket(response, question));
81
+ }
82
+ const unfulfilled = parseManagerEvidenceRequest(result.text);
83
+ if (unfulfilled) {
84
+ const limitation = { version: 1, requestId: unfulfilled.requestId, results: unfulfilled.operations.slice(0, 6).map(operation => ({ kind: operation.kind, status: "limited", limitation: "the two-round evidence lookup budget is exhausted; answer with this limitation disclosed" })), lookupRound: MANAGER_LOOKUP_MAX_ROUNDS, remainingRounds: 0, digest: report.digest };
85
+ result = await activeRole.builder.sendTurn(buildManagerEvidencePacket(limitation, question));
86
+ }
87
+ output.write(`${parseManagerEvidenceRequest(result.text) || looksLikeEvidenceRequest(result.text) ? "Manager could not complete an answer within the bounded evidence lookup budget; the available project report remains partial." : result.text.trim()}\n`);
88
+ usage = await activeRole.builder.sessionUsage?.() ?? usage;
89
+ metadata.record({ sessionId: managerSessionId, runId: initialReport.initialFocusRunId, provider: activeRole.builder.agent, startedAt, reportDigest: report.digest,
90
+ inputTokens: usage?.inputTokens, outputTokens: usage?.outputTokens, costUsd: usage?.authoritativeCostUsd, scope: "project", latestFocusRunId: currentFocusRunId,
91
+ projectReportDigest: report.projectDigest, lookupRounds, lookupOperations });
92
+ };
93
+ if (options.ask)
94
+ await ask(options.ask);
95
+ else {
96
+ const readline = createInterface({ input, output });
97
+ let interrupted = false;
98
+ const onInterrupt = () => { interrupted = true; readline.close(); };
99
+ readline.on("SIGINT", onInterrupt);
100
+ try {
101
+ while (true) {
102
+ const question = await readline.question("manager> ");
103
+ if (question.trim() === "/exit")
104
+ break;
105
+ if (question.trim())
106
+ await ask(question.trim());
107
+ }
108
+ }
109
+ catch (error) {
110
+ if (!interrupted && error.code !== "ABORT_ERR" && error.code !== "ERR_USE_AFTER_CLOSE")
111
+ throw error;
112
+ }
113
+ finally {
114
+ readline.off("SIGINT", onInterrupt);
115
+ readline.close();
116
+ }
117
+ }
118
+ metadata.record({ sessionId: managerSessionId, runId: initialReport.initialFocusRunId, provider: activeRole.builder.agent, startedAt, endedAt: new Date().toISOString(), outcome: "completed", reportDigest: packetState?.digest ?? initialReport.digest, inputTokens: usage?.inputTokens, outputTokens: usage?.outputTokens, costUsd: usage?.authoritativeCostUsd, scope: "project", latestFocusRunId: currentFocusRunId, projectReportDigest: packetState?.projectDigest ?? initialReport.projectDigest, lookupRounds, lookupOperations });
119
+ }
120
+ catch (error) {
121
+ metadata.record({ sessionId: managerSessionId, runId: initialReport.initialFocusRunId, provider: role?.builder.agent, startedAt, endedAt: new Date().toISOString(), outcome: "error", reportDigest: packetState?.digest ?? initialReport.digest, errorCode: "manager_error", scope: "project", latestFocusRunId: currentFocusRunId, projectReportDigest: packetState?.projectDigest ?? initialReport.projectDigest, lookupRounds, lookupOperations });
122
+ throw error;
123
+ }
124
+ finally {
125
+ await role?.builder.close().catch(() => { });
126
+ metadata.close();
127
+ }
128
+ }
129
+ function denyManagerTools() {
130
+ return { ...readOnlyPermissionConfig(), allowBash: [], allowTools: [], escalateBash: [""], escalateTools: ["Read", "Glob", "Grep", "Edit", "Write", "MultiEdit", "NotebookEdit", "TodoWrite", "WebFetch", "WebSearch", "Bash"] };
131
+ }
132
+ function stringOption(value) { return typeof value === "string" && value.length ? value : undefined; }
133
+ function validateExternal(value) { if (value === "auto" || value === "on" || value === "off")
134
+ return value; throw new Error("--external must be auto, on, or off"); }
135
+ function looksLikeEvidenceRequest(value) { return /^\s*(?:```(?:json)?\s*)?\{[\s\S]*"(?:operations|requestId)"/i.test(value); }
@@ -0,0 +1,4 @@
1
+ import { Command } from "commander";
2
+ export declare function buildAttachCommand(): Command;
3
+ export declare function buildDecideCommand(): Command;
4
+ export declare function buildStopCommand(): Command;
@@ -0,0 +1,80 @@
1
+ import { Command } from "commander";
2
+ import { resolve } from "node:path";
3
+ import { readBuildRuns } from "../buildRuns.js";
4
+ import { WorkflowDb } from "../workflowDb.js";
5
+ export function buildAttachCommand() {
6
+ return new Command("build:attach")
7
+ .description("Attach to a supervised build and show its durable recovery state.")
8
+ .argument("[project]", "project directory", ".")
9
+ .requiredOption("--run <id>", "build run ID")
10
+ .action((project, opts) => {
11
+ const projectDir = resolve(project);
12
+ const run = readBuildRuns(projectDir).find((item) => item.runId === opts.run);
13
+ if (!run)
14
+ throw new Error(`build run not found: ${opts.run}`);
15
+ const db = new WorkflowDb(projectDir);
16
+ try {
17
+ const supervisor = db.supervisorState(run.runId);
18
+ console.log(`rafi: run ${run.runId}`);
19
+ console.log(`rafi: phase ${"phase" in run && typeof run.phase === "string" ? run.phase : run.checkpoint}; status ${run.status}`);
20
+ console.log(`rafi: supervisor ${supervisor?.status ?? "unavailable"}${supervisor?.pid ? ` pid=${supervisor.pid}` : ""}`);
21
+ const attempts = db.recoveryAttempts(run.runId);
22
+ console.log(`rafi: recovery attempts ${attempts.length}`);
23
+ for (const decision of db.pendingHumanDecisions(run.runId))
24
+ console.log(`rafi: decision ${decision.decisionId}: ${decision.prompt} [${decision.choices.map((choice) => choice.id).join(" | ")}]`);
25
+ }
26
+ finally {
27
+ db.close();
28
+ }
29
+ });
30
+ }
31
+ export function buildDecideCommand() {
32
+ return new Command("build:decide")
33
+ .description("Answer a durable supervised-build decision.")
34
+ .argument("[project]", "project directory", ".")
35
+ .requiredOption("--run <id>", "build run ID")
36
+ .requiredOption("--decision <id>", "decision ID")
37
+ .requiredOption("--choice <id>", "stable choice ID")
38
+ .action((project, opts) => {
39
+ const db = new WorkflowDb(resolve(project));
40
+ try {
41
+ const decision = db.answerHumanDecision(opts.run, opts.decision, opts.choice);
42
+ console.log(`rafi: recorded ${decision.decisionId}=${opts.choice}`);
43
+ }
44
+ finally {
45
+ db.close();
46
+ }
47
+ });
48
+ }
49
+ export function buildStopCommand() {
50
+ return new Command("build:stop")
51
+ .description("Request graceful termination of a supervised build.")
52
+ .argument("[project]", "project directory", ".")
53
+ .requiredOption("--run <id>", "build run ID")
54
+ .action((project, opts) => {
55
+ const db = new WorkflowDb(resolve(project));
56
+ try {
57
+ const state = db.supervisorState(opts.run);
58
+ if (!state)
59
+ throw new Error(`supervisor state not found for run ${opts.run}`);
60
+ const next = { ...state, status: "stopping", stopRequestedAt: new Date().toISOString() };
61
+ db.putSupervisorState(opts.run, next);
62
+ if (state.workerPid) {
63
+ try {
64
+ process.kill(state.workerPid, "SIGTERM");
65
+ }
66
+ catch { /* stale worker */ }
67
+ }
68
+ if (state.pid && state.pid !== process.pid) {
69
+ try {
70
+ process.kill(state.pid, "SIGTERM");
71
+ }
72
+ catch { /* stale supervisor */ }
73
+ }
74
+ console.log(`rafi: stop requested for ${opts.run}`);
75
+ }
76
+ finally {
77
+ db.close();
78
+ }
79
+ });
80
+ }