@almightygpt/core 0.2.0

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 (126) hide show
  1. package/dist/adapters/claude.d.ts +31 -0
  2. package/dist/adapters/claude.d.ts.map +1 -0
  3. package/dist/adapters/claude.js +90 -0
  4. package/dist/adapters/claude.js.map +1 -0
  5. package/dist/adapters/gemini.d.ts +42 -0
  6. package/dist/adapters/gemini.d.ts.map +1 -0
  7. package/dist/adapters/gemini.js +133 -0
  8. package/dist/adapters/gemini.js.map +1 -0
  9. package/dist/adapters/index.d.ts +16 -0
  10. package/dist/adapters/index.d.ts.map +1 -0
  11. package/dist/adapters/index.js +15 -0
  12. package/dist/adapters/index.js.map +1 -0
  13. package/dist/adapters/mock.d.ts +23 -0
  14. package/dist/adapters/mock.d.ts.map +1 -0
  15. package/dist/adapters/mock.js +107 -0
  16. package/dist/adapters/mock.js.map +1 -0
  17. package/dist/adapters/openai.d.ts +38 -0
  18. package/dist/adapters/openai.d.ts.map +1 -0
  19. package/dist/adapters/openai.js +105 -0
  20. package/dist/adapters/openai.js.map +1 -0
  21. package/dist/adapters/types.d.ts +65 -0
  22. package/dist/adapters/types.d.ts.map +1 -0
  23. package/dist/adapters/types.js +26 -0
  24. package/dist/adapters/types.js.map +1 -0
  25. package/dist/config/load.d.ts +15 -0
  26. package/dist/config/load.d.ts.map +1 -0
  27. package/dist/config/load.js +46 -0
  28. package/dist/config/load.js.map +1 -0
  29. package/dist/config/schema.d.ts +260 -0
  30. package/dist/config/schema.d.ts.map +1 -0
  31. package/dist/config/schema.js +58 -0
  32. package/dist/config/schema.js.map +1 -0
  33. package/dist/context/manifest.d.ts +58 -0
  34. package/dist/context/manifest.d.ts.map +1 -0
  35. package/dist/context/manifest.js +49 -0
  36. package/dist/context/manifest.js.map +1 -0
  37. package/dist/context/redact.d.ts +26 -0
  38. package/dist/context/redact.d.ts.map +1 -0
  39. package/dist/context/redact.js +67 -0
  40. package/dist/context/redact.js.map +1 -0
  41. package/dist/git/status.d.ts +48 -0
  42. package/dist/git/status.d.ts.map +1 -0
  43. package/dist/git/status.js +79 -0
  44. package/dist/git/status.js.map +1 -0
  45. package/dist/index.d.ts +33 -0
  46. package/dist/index.d.ts.map +1 -0
  47. package/dist/index.js +38 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/review/budget.d.ts +46 -0
  50. package/dist/review/budget.d.ts.map +1 -0
  51. package/dist/review/budget.js +83 -0
  52. package/dist/review/budget.js.map +1 -0
  53. package/dist/review/diff.d.ts +21 -0
  54. package/dist/review/diff.d.ts.map +1 -0
  55. package/dist/review/diff.js +55 -0
  56. package/dist/review/diff.js.map +1 -0
  57. package/dist/review/events.d.ts +76 -0
  58. package/dist/review/events.d.ts.map +1 -0
  59. package/dist/review/events.js +13 -0
  60. package/dist/review/events.js.map +1 -0
  61. package/dist/review/memory.d.ts +23 -0
  62. package/dist/review/memory.d.ts.map +1 -0
  63. package/dist/review/memory.js +42 -0
  64. package/dist/review/memory.js.map +1 -0
  65. package/dist/review/prompts.d.ts +34 -0
  66. package/dist/review/prompts.d.ts.map +1 -0
  67. package/dist/review/prompts.js +174 -0
  68. package/dist/review/prompts.js.map +1 -0
  69. package/dist/review/run-diff-review.d.ts +52 -0
  70. package/dist/review/run-diff-review.d.ts.map +1 -0
  71. package/dist/review/run-diff-review.js +258 -0
  72. package/dist/review/run-diff-review.js.map +1 -0
  73. package/dist/review/run-worker-reviewer.d.ts +72 -0
  74. package/dist/review/run-worker-reviewer.d.ts.map +1 -0
  75. package/dist/review/run-worker-reviewer.js +407 -0
  76. package/dist/review/run-worker-reviewer.js.map +1 -0
  77. package/dist/review/write.d.ts +44 -0
  78. package/dist/review/write.d.ts.map +1 -0
  79. package/dist/review/write.js +152 -0
  80. package/dist/review/write.js.map +1 -0
  81. package/dist/runs/decide.d.ts +45 -0
  82. package/dist/runs/decide.d.ts.map +1 -0
  83. package/dist/runs/decide.js +93 -0
  84. package/dist/runs/decide.js.map +1 -0
  85. package/dist/runs/folder.d.ts +42 -0
  86. package/dist/runs/folder.d.ts.map +1 -0
  87. package/dist/runs/folder.js +82 -0
  88. package/dist/runs/folder.js.map +1 -0
  89. package/dist/runs/list.d.ts +58 -0
  90. package/dist/runs/list.d.ts.map +1 -0
  91. package/dist/runs/list.js +117 -0
  92. package/dist/runs/list.js.map +1 -0
  93. package/dist/runs/types.d.ts +96 -0
  94. package/dist/runs/types.d.ts.map +1 -0
  95. package/dist/runs/types.js +13 -0
  96. package/dist/runs/types.js.map +1 -0
  97. package/dist/templates/install.d.ts +49 -0
  98. package/dist/templates/install.d.ts.map +1 -0
  99. package/dist/templates/install.js +154 -0
  100. package/dist/templates/install.js.map +1 -0
  101. package/package.json +34 -0
  102. package/src/adapters/claude.ts +133 -0
  103. package/src/adapters/gemini.ts +183 -0
  104. package/src/adapters/index.ts +21 -0
  105. package/src/adapters/mock.ts +125 -0
  106. package/src/adapters/openai.ts +150 -0
  107. package/src/adapters/types.ts +73 -0
  108. package/src/config/load.ts +61 -0
  109. package/src/config/schema.ts +64 -0
  110. package/src/context/manifest.ts +94 -0
  111. package/src/context/redact.ts +93 -0
  112. package/src/git/status.ts +108 -0
  113. package/src/index.ts +127 -0
  114. package/src/review/budget.ts +116 -0
  115. package/src/review/diff.ts +85 -0
  116. package/src/review/events.ts +86 -0
  117. package/src/review/memory.ts +57 -0
  118. package/src/review/prompts.ts +208 -0
  119. package/src/review/run-diff-review.ts +353 -0
  120. package/src/review/run-worker-reviewer.ts +528 -0
  121. package/src/review/write.ts +208 -0
  122. package/src/runs/decide.ts +153 -0
  123. package/src/runs/folder.ts +137 -0
  124. package/src/runs/list.ts +152 -0
  125. package/src/runs/types.ts +98 -0
  126. package/src/templates/install.ts +198 -0
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Build the context-manifest.json that accompanies every run.
3
+ *
4
+ * The manifest is the auditable record of what was sent to the model:
5
+ * - which files were included,
6
+ * - which were skipped and why,
7
+ * - file content hashes (so re-runs against unchanged files can be
8
+ * short-circuited later — caching is task #14+ scope but the hashes are
9
+ * captured now to keep the format stable),
10
+ * - redaction counts so the user can see how many secrets were filtered.
11
+ *
12
+ * For MVP 1 diff review, the "files" are the files in the diff; the manifest
13
+ * does not currently re-read full file contents (that's task #13 expansion
14
+ * for path review). Token estimates are 4-char heuristics.
15
+ */
16
+
17
+ import { writeFile } from "node:fs/promises";
18
+ import { createHash } from "node:crypto";
19
+ import { join } from "node:path";
20
+ import type { RedactionMatch } from "./redact.js";
21
+
22
+ export interface ManifestFile {
23
+ path: string;
24
+ bytes: number;
25
+ sha256: string;
26
+ /** Why this file was excluded, if so. Undefined means included. */
27
+ skippedReason?: string;
28
+ }
29
+
30
+ export interface ContextManifest {
31
+ generatedAt: string;
32
+ inputSource: "diff" | "diff-range" | "path" | "requirement-file" | "ask";
33
+ filesIncluded: ManifestFile[];
34
+ filesSkipped: ManifestFile[];
35
+ redaction: {
36
+ enabled: boolean;
37
+ totalCount: number;
38
+ byKind: RedactionMatch[];
39
+ };
40
+ totalTokensEstimate: number;
41
+ diffBytes: number;
42
+ }
43
+
44
+ export interface BuildManifestOptions {
45
+ inputSource: ContextManifest["inputSource"];
46
+ filesIncluded: { path: string; bytes: number; content?: string }[];
47
+ filesSkipped: { path: string; bytes: number; skippedReason: string }[];
48
+ diffText: string;
49
+ redaction: { enabled: boolean; totalCount: number; byKind: RedactionMatch[] };
50
+ }
51
+
52
+ export function buildContextManifest(
53
+ opts: BuildManifestOptions,
54
+ ): ContextManifest {
55
+ const included: ManifestFile[] = opts.filesIncluded.map((f) => ({
56
+ path: f.path,
57
+ bytes: f.bytes,
58
+ sha256: f.content ? sha256(f.content) : "",
59
+ }));
60
+
61
+ const skipped: ManifestFile[] = opts.filesSkipped.map((f) => ({
62
+ path: f.path,
63
+ bytes: f.bytes,
64
+ sha256: "",
65
+ skippedReason: f.skippedReason,
66
+ }));
67
+
68
+ return {
69
+ generatedAt: new Date().toISOString(),
70
+ inputSource: opts.inputSource,
71
+ filesIncluded: included,
72
+ filesSkipped: skipped,
73
+ redaction: opts.redaction,
74
+ totalTokensEstimate: Math.ceil(opts.diffText.length / 4),
75
+ diffBytes: opts.diffText.length,
76
+ };
77
+ }
78
+
79
+ export async function writeContextManifest(
80
+ runFolderAbsPath: string,
81
+ manifest: ContextManifest,
82
+ ): Promise<string> {
83
+ const relPath = "context-manifest.json";
84
+ await writeFile(
85
+ join(runFolderAbsPath, relPath),
86
+ JSON.stringify(manifest, null, 2) + "\n",
87
+ "utf8",
88
+ );
89
+ return relPath;
90
+ }
91
+
92
+ function sha256(s: string): string {
93
+ return createHash("sha256").update(s).digest("hex");
94
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Secret redaction.
3
+ *
4
+ * Runs a battery of regex patterns over text and replaces matches with a
5
+ * `[REDACTED:<kind>:<short-hash>]` placeholder. The hash lets the user
6
+ * confirm two redactions refer to the same secret (useful for audit) without
7
+ * leaking the value.
8
+ *
9
+ * This is a defense-in-depth measure. The first line of defense is the
10
+ * `.almightyignore` file which prevents secret-bearing files from being
11
+ * included in the first place. Redaction catches what slips through.
12
+ *
13
+ * Patterns are deliberately conservative — false positives are better than
14
+ * leaking real secrets. Users can disable redaction via config if needed.
15
+ */
16
+
17
+ import { createHash } from "node:crypto";
18
+
19
+ export interface RedactionMatch {
20
+ kind: string;
21
+ count: number;
22
+ }
23
+
24
+ export interface RedactionResult {
25
+ text: string;
26
+ redactions: RedactionMatch[];
27
+ totalCount: number;
28
+ }
29
+
30
+ interface Pattern {
31
+ kind: string;
32
+ regex: RegExp;
33
+ }
34
+
35
+ const PATTERNS: Pattern[] = [
36
+ // OpenAI keys
37
+ { kind: "openai_key", regex: /\bsk-[A-Za-z0-9]{20,}\b/g },
38
+ // GitHub PATs (classic + fine-grained)
39
+ { kind: "github_pat", regex: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
40
+ // Anthropic keys
41
+ { kind: "anthropic_key", regex: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
42
+ // Generic AWS access key id
43
+ { kind: "aws_access_key_id", regex: /\b(AKIA|ASIA)[A-Z0-9]{16}\b/g },
44
+ // Slack bot token
45
+ { kind: "slack_token", regex: /\bxox[abp]-[A-Za-z0-9-]{20,}\b/g },
46
+ // Generic JWT (3 base64url segments)
47
+ {
48
+ kind: "jwt",
49
+ regex: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g,
50
+ },
51
+ // PEM private key block
52
+ {
53
+ kind: "pem_private_key",
54
+ regex:
55
+ /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]+?-----END (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/g,
56
+ },
57
+ // Bearer tokens in headers (catches "Authorization: Bearer <token>")
58
+ {
59
+ kind: "bearer_token",
60
+ regex: /\b[Bb]earer\s+[A-Za-z0-9_\-.~+/=]{16,}/g,
61
+ },
62
+ // Generic high-entropy key= or password= assignments in code / yaml / env
63
+ // (catches things like API_KEY="ABCDEFG..." or password: '...')
64
+ {
65
+ kind: "assignment_secret",
66
+ regex:
67
+ /\b(?:api[_-]?key|secret|password|token|access[_-]?key)\s*[:=]\s*["']?([A-Za-z0-9+/=_\-.]{20,})["']?/gi,
68
+ },
69
+ ];
70
+
71
+ export function redactSecrets(text: string): RedactionResult {
72
+ const counts = new Map<string, number>();
73
+ let out = text;
74
+
75
+ for (const { kind, regex } of PATTERNS) {
76
+ out = out.replace(regex, (match) => {
77
+ const hash = shortHash(match);
78
+ counts.set(kind, (counts.get(kind) ?? 0) + 1);
79
+ return `[REDACTED:${kind}:${hash}]`;
80
+ });
81
+ }
82
+
83
+ const redactions: RedactionMatch[] = [...counts.entries()].map(
84
+ ([kind, count]) => ({ kind, count }),
85
+ );
86
+ const totalCount = redactions.reduce((sum, r) => sum + r.count, 0);
87
+
88
+ return { text: out, redactions, totalCount };
89
+ }
90
+
91
+ function shortHash(s: string): string {
92
+ return createHash("sha256").update(s).digest("hex").slice(0, 8);
93
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Git status safety check.
3
+ *
4
+ * Before any agent (or `almightygpt init`) writes a file that already exists,
5
+ * we check whether the target has uncommitted changes. If it does, refuse to
6
+ * overwrite — the user has work in progress that we should not silently
7
+ * destroy.
8
+ *
9
+ * This is the same rule the project's own CLAUDE.md enforces for the team,
10
+ * and it ships in the default Convention Pack rules so every adopter gets
11
+ * the same protection.
12
+ */
13
+
14
+ import { execa } from "execa";
15
+
16
+ export interface GitStatusCheck {
17
+ /** True if the file is tracked but modified, staged, or is an untracked path. */
18
+ dirty: boolean;
19
+ /** True if the target path is not inside a git repository. */
20
+ notInGitRepo?: boolean;
21
+ /** Raw git porcelain output if dirty, useful for surfacing context to the user. */
22
+ porcelain?: string;
23
+ }
24
+
25
+ /**
26
+ * Check whether a path has uncommitted changes in its containing git repo.
27
+ *
28
+ * - Returns `{ dirty: false, notInGitRepo: true }` if `repoRoot` is not a git
29
+ * working tree. Callers may treat this as "no protection available; proceed".
30
+ * - Returns `{ dirty: false }` if the path is clean (or does not exist yet).
31
+ * - Returns `{ dirty: true, porcelain }` if `git status --short` reports
32
+ * anything for the path.
33
+ *
34
+ * The relative path is normalized to use forward slashes (git's preferred form).
35
+ */
36
+ export async function checkGitStatus(
37
+ repoRoot: string,
38
+ relativePath: string,
39
+ ): Promise<GitStatusCheck> {
40
+ const normalized = relativePath.replace(/\\/g, "/");
41
+
42
+ try {
43
+ const { stdout } = await execa(
44
+ "git",
45
+ ["status", "--short", "--", normalized],
46
+ {
47
+ cwd: repoRoot,
48
+ reject: false,
49
+ stripFinalNewline: true,
50
+ },
51
+ );
52
+
53
+ if (stdout.trim().length === 0) {
54
+ return { dirty: false };
55
+ }
56
+
57
+ return { dirty: true, porcelain: stdout };
58
+ } catch (err) {
59
+ // execa throws if git is missing. Surface as not-in-repo so callers can
60
+ // proceed cautiously rather than crash.
61
+ const msg = err instanceof Error ? err.message : String(err);
62
+ if (msg.includes("ENOENT")) {
63
+ return { dirty: false, notInGitRepo: true };
64
+ }
65
+ // "not a git repository" returns non-zero but we used reject:false above,
66
+ // so this branch is rare. Still, treat unknown errors as no-protection.
67
+ return { dirty: false, notInGitRepo: true };
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Assert that a path is safe to overwrite. Throws if dirty unless `force` is set.
73
+ *
74
+ * Intended call sites:
75
+ * - `almightygpt init` when a convention file already exists.
76
+ * - Reviewer adapters before writing `docs/<reviewer>-reviews/<topic>.md`.
77
+ * - Any future agent that writes Markdown into a tracked path.
78
+ */
79
+ export async function assertSafeToWrite(
80
+ repoRoot: string,
81
+ relativePath: string,
82
+ force = false,
83
+ ): Promise<void> {
84
+ if (force) return;
85
+
86
+ const status = await checkGitStatus(repoRoot, relativePath);
87
+ if (!status.dirty) return;
88
+
89
+ const lines = (status.porcelain ?? "").split("\n").filter(Boolean);
90
+ const detail = lines.map((l) => ` ${l}`).join("\n");
91
+ throw new GitStatusDirtyError(
92
+ `Refusing to overwrite ${relativePath} — it has uncommitted changes:\n${detail}\n\n` +
93
+ `Commit or stash the changes, or re-run with --force to override.`,
94
+ relativePath,
95
+ status.porcelain ?? "",
96
+ );
97
+ }
98
+
99
+ export class GitStatusDirtyError extends Error {
100
+ override readonly name = "GitStatusDirtyError";
101
+ constructor(
102
+ message: string,
103
+ public readonly path: string,
104
+ public readonly porcelain: string,
105
+ ) {
106
+ super(message);
107
+ }
108
+ }
package/src/index.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @almightygpt/core
3
+ *
4
+ * Core orchestrator, adapters, config, runs, and review logic.
5
+ * MVP 1 status:
6
+ * - git/ ✅ task #8
7
+ * - templates/ ✅ task #7 installer (used by init, task #6)
8
+ * - config/ ✅ task #11 schema + loader
9
+ * - adapters/ ✅ task #9 mock + task #10 OpenAI
10
+ * - context/ ✅ task #13 manifest + secret redaction
11
+ * - runs/ ✅ task #12 dual-artifact folder + run.json
12
+ * - review/ ✅ task #11 diff review pipeline (with #12/#13/#14 wiring)
13
+ * - budget/ ✅ task #14 BudgetTracker + BudgetExceededError
14
+ */
15
+
16
+ export const VERSION = "0.2.0";
17
+
18
+ // Git safety primitives
19
+ export {
20
+ checkGitStatus,
21
+ assertSafeToWrite,
22
+ GitStatusDirtyError,
23
+ type GitStatusCheck,
24
+ } from "./git/status.js";
25
+
26
+ // Template installer
27
+ export {
28
+ installTemplate,
29
+ hasExistingConfig,
30
+ type InstallOptions,
31
+ type InstallResult,
32
+ } from "./templates/install.js";
33
+
34
+ // Config
35
+ export { loadConfig, ConfigError } from "./config/load.js";
36
+ export {
37
+ ConfigSchema,
38
+ AgentConfigSchema,
39
+ AgentRoleSchema,
40
+ type Config,
41
+ type AgentConfig,
42
+ } from "./config/schema.js";
43
+
44
+ // Adapters
45
+ export {
46
+ AdapterError,
47
+ MockAdapter,
48
+ OpenAIAdapter,
49
+ ClaudeAdapter,
50
+ GeminiAdapter,
51
+ type Adapter,
52
+ type AdapterInput,
53
+ type AdapterOutput,
54
+ type AgentRole,
55
+ type OpenAIAdapterOptions,
56
+ type ClaudeAdapterOptions,
57
+ type GeminiAdapterOptions,
58
+ } from "./adapters/index.js";
59
+
60
+ // Context / redaction
61
+ export { redactSecrets, type RedactionResult, type RedactionMatch } from "./context/redact.js";
62
+ export {
63
+ buildContextManifest,
64
+ writeContextManifest,
65
+ type ContextManifest,
66
+ type ManifestFile,
67
+ } from "./context/manifest.js";
68
+
69
+ // Runs
70
+ export {
71
+ createRunFolder,
72
+ writeRunMetadata,
73
+ writeRunInput,
74
+ writeAgentOutput,
75
+ collectGitContext,
76
+ type CreatedRunFolder,
77
+ } from "./runs/folder.js";
78
+ export type {
79
+ RunMetadata,
80
+ RunStatus,
81
+ RunType,
82
+ AgentMetrics,
83
+ HumanDecision,
84
+ DecisionStatus,
85
+ } from "./runs/types.js";
86
+ export {
87
+ listRuns,
88
+ findRunById,
89
+ findLatestRun,
90
+ formatDuration,
91
+ type RunSummary,
92
+ type ListRunsResult,
93
+ type ListRunsOptions,
94
+ } from "./runs/list.js";
95
+ export {
96
+ recordDecision,
97
+ type RecordDecisionOptions,
98
+ type RecordDecisionResult,
99
+ } from "./runs/decide.js";
100
+
101
+ // Review pipeline
102
+ export {
103
+ runDiffReview,
104
+ type DiffReviewOptions,
105
+ type DiffReviewResult,
106
+ } from "./review/run-diff-review.js";
107
+ export {
108
+ runWorkerReviewerReview,
109
+ type WorkerReviewerOptions,
110
+ type WorkerReviewerResult,
111
+ } from "./review/run-worker-reviewer.js";
112
+ export { collectGitDiff, type DiffOptions, type DiffResult } from "./review/diff.js";
113
+ export {
114
+ writeHumanReviewFile,
115
+ ReviewFileExistsError,
116
+ type WriteReviewFileOptions,
117
+ } from "./review/write.js";
118
+ export {
119
+ BudgetTracker,
120
+ BudgetExceededError,
121
+ type BudgetCaps,
122
+ } from "./review/budget.js";
123
+ export type {
124
+ ReviewEvent,
125
+ ReviewEventHandler,
126
+ AgentRoleInRun,
127
+ } from "./review/events.js";
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Budget tracking and enforcement for review runs.
3
+ *
4
+ * Two checks:
5
+ * - Pre-flight: estimate cost from prompt size + max output tokens, abort
6
+ * if estimate already exceeds maxCostPerRunUsd.
7
+ * - Post-call: aggregate actual cost across all adapter calls in the run,
8
+ * abort if cap is breached.
9
+ *
10
+ * Estimates use the same PRICING_USD_PER_1M table as the OpenAI adapter.
11
+ * For non-OpenAI providers we fall back to a conservative default.
12
+ */
13
+
14
+ const DEFAULT_INPUT_USD_PER_1M = 3.0;
15
+ const DEFAULT_OUTPUT_USD_PER_1M = 15.0;
16
+
17
+ const KNOWN_PRICING: Record<string, { input: number; output: number }> = {
18
+ "gpt-4o": { input: 2.5, output: 10.0 },
19
+ "gpt-4o-mini": { input: 0.15, output: 0.6 },
20
+ "gpt-4-turbo": { input: 10.0, output: 30.0 },
21
+ "gpt-3.5-turbo": { input: 0.5, output: 1.5 },
22
+ "claude-3-5-sonnet": { input: 3.0, output: 15.0 },
23
+ "claude-3-5-haiku": { input: 0.8, output: 4.0 },
24
+ "claude-3-opus": { input: 15.0, output: 75.0 },
25
+ "gemini-2.5-pro": { input: 1.25, output: 10.0 },
26
+ "gemini-2.5-flash": { input: 0.3, output: 2.5 },
27
+ "gemini-2.0-flash": { input: 0.1, output: 0.4 },
28
+ "gemini-1.5-pro": { input: 1.25, output: 5.0 },
29
+ "gemini-1.5-flash": { input: 0.075, output: 0.3 },
30
+ };
31
+
32
+ export class BudgetExceededError extends Error {
33
+ override readonly name = "BudgetExceededError";
34
+ constructor(
35
+ message: string,
36
+ public readonly stage: "pre-flight" | "post-call",
37
+ public readonly capUsd: number,
38
+ public readonly actualUsd: number,
39
+ ) {
40
+ super(message);
41
+ }
42
+ }
43
+
44
+ export interface BudgetCaps {
45
+ maxCostPerRunUsd: number;
46
+ maxTokensPerRun: number;
47
+ }
48
+
49
+ export class BudgetTracker {
50
+ private totalCostUsd = 0;
51
+ private totalTokens = 0;
52
+
53
+ constructor(private readonly caps: BudgetCaps) {}
54
+
55
+ /** Conservative pre-flight check before calling an adapter. */
56
+ preflightCheck(opts: {
57
+ model: string;
58
+ estimatedTokensIn: number;
59
+ maxOutputTokens: number;
60
+ }): void {
61
+ const rates = lookupPricing(opts.model);
62
+ const estimatedCost =
63
+ (opts.estimatedTokensIn / 1_000_000) * rates.input +
64
+ (opts.maxOutputTokens / 1_000_000) * rates.output;
65
+ const projected = this.totalCostUsd + estimatedCost;
66
+
67
+ if (projected > this.caps.maxCostPerRunUsd) {
68
+ throw new BudgetExceededError(
69
+ `Pre-flight cost estimate $${projected.toFixed(4)} exceeds budget cap ` +
70
+ `$${this.caps.maxCostPerRunUsd.toFixed(4)}. Lower max_output_tokens, ` +
71
+ `shrink the diff, or raise budget.maxCostPerRunUsd in .almightygpt/config.yaml.`,
72
+ "pre-flight",
73
+ this.caps.maxCostPerRunUsd,
74
+ projected,
75
+ );
76
+ }
77
+ }
78
+
79
+ /** Record actual usage after a successful adapter call; trip the cap if exceeded. */
80
+ record(opts: { tokensIn: number; tokensOut: number; costUsd: number }): void {
81
+ this.totalCostUsd += opts.costUsd;
82
+ this.totalTokens += opts.tokensIn + opts.tokensOut;
83
+
84
+ if (this.totalCostUsd > this.caps.maxCostPerRunUsd) {
85
+ throw new BudgetExceededError(
86
+ `Run cost $${this.totalCostUsd.toFixed(4)} exceeded cap ` +
87
+ `$${this.caps.maxCostPerRunUsd.toFixed(4)} after adapter call.`,
88
+ "post-call",
89
+ this.caps.maxCostPerRunUsd,
90
+ this.totalCostUsd,
91
+ );
92
+ }
93
+ if (this.totalTokens > this.caps.maxTokensPerRun) {
94
+ throw new BudgetExceededError(
95
+ `Run tokens ${this.totalTokens} exceeded cap ${this.caps.maxTokensPerRun}.`,
96
+ "post-call",
97
+ this.caps.maxTokensPerRun,
98
+ this.totalTokens,
99
+ );
100
+ }
101
+ }
102
+
103
+ get totals(): { costUsd: number; tokens: number } {
104
+ return { costUsd: this.totalCostUsd, tokens: this.totalTokens };
105
+ }
106
+ }
107
+
108
+ function lookupPricing(model: string): { input: number; output: number } {
109
+ const key = Object.keys(KNOWN_PRICING).find((k) =>
110
+ model.toLowerCase().startsWith(k),
111
+ );
112
+ if (!key) {
113
+ return { input: DEFAULT_INPUT_USD_PER_1M, output: DEFAULT_OUTPUT_USD_PER_1M };
114
+ }
115
+ return KNOWN_PRICING[key]!;
116
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Collect a git diff for review.
3
+ *
4
+ * Default: uncommitted working tree changes (staged + unstaged).
5
+ * Optional range: e.g. "HEAD~1..HEAD" to review the last commit.
6
+ */
7
+
8
+ import { execa } from "execa";
9
+
10
+ export interface DiffResult {
11
+ diff: string;
12
+ /** Files touched in the diff (relative paths). */
13
+ files: string[];
14
+ /** True if the working tree had no changes and no range was supplied. */
15
+ empty: boolean;
16
+ }
17
+
18
+ export interface DiffOptions {
19
+ /** Optional git range like "HEAD~3..HEAD". If omitted, uncommitted changes. */
20
+ range?: string;
21
+ /** Hard cap on diff size to avoid blowing the context window. Default 200 KB. */
22
+ maxBytes?: number;
23
+ }
24
+
25
+ export async function collectGitDiff(
26
+ repoRoot: string,
27
+ opts: DiffOptions = {},
28
+ ): Promise<DiffResult> {
29
+ const maxBytes = opts.maxBytes ?? 200_000;
30
+ const args = ["diff", "--no-color"];
31
+ if (opts.range) {
32
+ args.push(opts.range);
33
+ } else {
34
+ // Include staged + unstaged + untracked-vs-HEAD.
35
+ args.push("HEAD");
36
+ }
37
+
38
+ const { stdout, exitCode } = await execa("git", args, {
39
+ cwd: repoRoot,
40
+ reject: false,
41
+ stripFinalNewline: false,
42
+ maxBuffer: maxBytes * 2,
43
+ });
44
+
45
+ // exitCode is 0 on success, 1 on dirty diff in some git modes; treat both as ok.
46
+ if (exitCode !== 0 && exitCode !== 1) {
47
+ throw new Error(
48
+ `git diff failed with exit ${exitCode}. Args: ${args.join(" ")}`,
49
+ );
50
+ }
51
+
52
+ const truncated =
53
+ stdout.length > maxBytes
54
+ ? stdout.slice(0, maxBytes) +
55
+ `\n\n[…truncated — diff exceeded ${maxBytes} bytes]\n`
56
+ : stdout;
57
+
58
+ const files = await collectFilesInDiff(repoRoot, opts.range);
59
+
60
+ return {
61
+ diff: truncated,
62
+ files,
63
+ empty: truncated.trim().length === 0,
64
+ };
65
+ }
66
+
67
+ async function collectFilesInDiff(
68
+ repoRoot: string,
69
+ range?: string,
70
+ ): Promise<string[]> {
71
+ const args = ["diff", "--name-only"];
72
+ if (range) args.push(range);
73
+ else args.push("HEAD");
74
+
75
+ const { stdout } = await execa("git", args, {
76
+ cwd: repoRoot,
77
+ reject: false,
78
+ stripFinalNewline: true,
79
+ });
80
+
81
+ return stdout
82
+ .split("\n")
83
+ .map((s) => s.trim())
84
+ .filter(Boolean);
85
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Review run event stream.
3
+ *
4
+ * Emitted by review pipelines via an optional `onEvent` callback. The CLI
5
+ * uses these events to drive both human progress output and the structured
6
+ * --json stream that future VS Code / dashboard surfaces will consume.
7
+ *
8
+ * Each event is a discriminated union — the `type` field is the
9
+ * discriminator. Tooling should be tolerant of unknown event types so we
10
+ * can add new ones without breaking existing consumers.
11
+ */
12
+
13
+ export type AgentRoleInRun = "worker" | "reviewer" | "judge";
14
+
15
+ export type ReviewEvent =
16
+ | RunStartedEvent
17
+ | AgentStartedEvent
18
+ | AgentCompletedEvent
19
+ | RedactionCompleteEvent
20
+ | ReviewWrittenEvent
21
+ | RunCompletedEvent
22
+ | RunFailedEvent;
23
+
24
+ export interface RunStartedEvent {
25
+ type: "run_started";
26
+ runId: string;
27
+ runType: string;
28
+ topic: string;
29
+ reviewsDir: string;
30
+ runFolder: string;
31
+ }
32
+
33
+ export interface AgentStartedEvent {
34
+ type: "agent_started";
35
+ role: AgentRoleInRun;
36
+ agent: string;
37
+ provider: string;
38
+ }
39
+
40
+ export interface AgentCompletedEvent {
41
+ type: "agent_completed";
42
+ role: AgentRoleInRun;
43
+ agent: string;
44
+ provider: string;
45
+ model: string;
46
+ outputPath: string;
47
+ tokensIn: number;
48
+ tokensOut: number;
49
+ costUsd: number;
50
+ latencyMs: number;
51
+ }
52
+
53
+ export interface RedactionCompleteEvent {
54
+ type: "redaction_complete";
55
+ totalCount: number;
56
+ byKind: { kind: string; count: number }[];
57
+ }
58
+
59
+ export interface ReviewWrittenEvent {
60
+ type: "review_written";
61
+ reviewPath: string;
62
+ bytes: number;
63
+ shallowWarning?: string;
64
+ }
65
+
66
+ export interface RunCompletedEvent {
67
+ type: "run_completed";
68
+ runId: string;
69
+ reviewPath: string;
70
+ runFolder: string;
71
+ totals: {
72
+ tokensIn: number;
73
+ tokensOut: number;
74
+ costUsd: number;
75
+ latencyMs: number;
76
+ };
77
+ }
78
+
79
+ export interface RunFailedEvent {
80
+ type: "run_failed";
81
+ runId: string;
82
+ error: { name: string; message: string };
83
+ }
84
+
85
+ /** Callback signature for receiving the event stream. */
86
+ export type ReviewEventHandler = (event: ReviewEvent) => void;