@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,208 @@
1
+ /**
2
+ * Write the human-facing review file at docs/<reviewer>-reviews/<topic>.md.
3
+ *
4
+ * The topic-named file overwrites on re-run; audit history is preserved by
5
+ * git. Before writing, the path is checked against `git status --short` —
6
+ * if dirty, we refuse to overwrite unless `force` is set.
7
+ *
8
+ * The orchestrator wraps the Reviewer's raw markdown in a small header
9
+ * (metadata) and footer (cost/latency + appendix pointers) so the file is
10
+ * self-contained without depending on the run folder.
11
+ */
12
+
13
+ import { mkdir, writeFile } from "node:fs/promises";
14
+ import { existsSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { assertSafeToWrite } from "../git/status.js";
17
+
18
+ export class ReviewFileExistsError extends Error {
19
+ override readonly name = "ReviewFileExistsError";
20
+ constructor(message: string, public readonly path: string) {
21
+ super(message);
22
+ }
23
+ }
24
+
25
+ export interface WriteReviewFileOptions {
26
+ repoRoot: string;
27
+ reviewsDir: string;
28
+ topic: string;
29
+ reviewerName: string;
30
+ reviewerProvider: string;
31
+ modelUsed: string;
32
+ /** The Reviewer's raw markdown output. */
33
+ body: string;
34
+ /** Cost/latency to render in the footer. */
35
+ metrics: {
36
+ tokensIn: number;
37
+ tokensOut: number;
38
+ costUsd: number;
39
+ latencyMs: number;
40
+ };
41
+ /** Optional run-folder relative path for the appendix pointer. */
42
+ runFolder?: string;
43
+ /** Optional shallow-review warning to prefix at the top of the file. */
44
+ shallowWarning?: string;
45
+ force?: boolean;
46
+ }
47
+
48
+ export interface WrittenReviewFile {
49
+ path: string;
50
+ bytes: number;
51
+ }
52
+
53
+ export async function writeHumanReviewFile(
54
+ opts: WriteReviewFileOptions,
55
+ ): Promise<WrittenReviewFile> {
56
+ const safeTopic = sanitizeTopic(opts.topic);
57
+ const relPath = join(opts.reviewsDir, `${safeTopic}.md`);
58
+ const absPath = join(opts.repoRoot, relPath);
59
+
60
+ // Defensive default: never silently overwrite an existing review file.
61
+ // Even a clean (committed) file should require explicit --force, because
62
+ // accidental topic collisions are easy and the user's previous review may
63
+ // be the answer they wanted to keep on top. Git history preserves the old
64
+ // version when --force is used, so no data is truly lost.
65
+ if (existsSync(absPath) && !opts.force) {
66
+ // Still run the git-status check so callers get a richer dirty-file
67
+ // message when applicable; falls through to the existence error below.
68
+ await assertSafeToWrite(opts.repoRoot, relPath, false);
69
+ throw new ReviewFileExistsError(
70
+ `Refusing to overwrite ${relPath}. Pass --force to overwrite (the ` +
71
+ `previous version remains in git history). Alternatively pick a ` +
72
+ `different --topic to keep both reviews.`,
73
+ relPath,
74
+ );
75
+ }
76
+
77
+ // When --force is set, we still want the dirty-file check unless the user
78
+ // also passed it explicitly; preserve assertSafeToWrite's prior behavior.
79
+ await assertSafeToWrite(opts.repoRoot, relPath, opts.force);
80
+
81
+ await mkdir(dirname(absPath), { recursive: true });
82
+
83
+ const content = renderReviewMarkdown(opts);
84
+ await writeFile(absPath, content, "utf8");
85
+
86
+ return { path: relPath, bytes: content.length };
87
+ }
88
+
89
+ function sanitizeTopic(topic: string): string {
90
+ // Allow letters, digits, dot, dash, underscore. Replace everything else
91
+ // with a single dash. Trim leading/trailing dashes. Lowercase.
92
+ const cleaned = topic
93
+ .trim()
94
+ .toLowerCase()
95
+ .replace(/[^a-z0-9._-]+/g, "-")
96
+ .replace(/^-+|-+$/g, "");
97
+ if (!cleaned) {
98
+ throw new Error(
99
+ `Topic "${topic}" produced an empty sanitized name. Pick a topic with ` +
100
+ `at least one alphanumeric character.`,
101
+ );
102
+ }
103
+ return cleaned;
104
+ }
105
+
106
+ function renderReviewMarkdown(opts: WriteReviewFileOptions): string {
107
+ const now = new Date().toISOString();
108
+ const cost = opts.metrics.costUsd.toFixed(4);
109
+ const headerLines = [
110
+ `# Review: ${opts.topic}`,
111
+ "",
112
+ `> Reviewer: \`${opts.reviewerName}\` (${opts.reviewerProvider}, ${opts.modelUsed}) `,
113
+ `> Generated: ${now} `,
114
+ `> Tokens: ${opts.metrics.tokensIn} in / ${opts.metrics.tokensOut} out `,
115
+ `> Cost: $${cost} USD `,
116
+ `> Latency: ${(opts.metrics.latencyMs / 1000).toFixed(2)}s `,
117
+ opts.runFolder ? `> Run folder: \`${opts.runFolder}\` ` : "",
118
+ "",
119
+ ].filter((line, i, arr) => !(line === "" && arr[i - 1] === ""));
120
+
121
+ const sections: string[] = [headerLines.join("\n")];
122
+
123
+ if (opts.shallowWarning) {
124
+ sections.push(
125
+ [
126
+ "> ⚠️ **SHALLOW REVIEW WARNING**",
127
+ ">",
128
+ `> ${opts.shallowWarning}`,
129
+ ">",
130
+ "> Consider re-running with a different reviewer or model.",
131
+ "",
132
+ ].join("\n"),
133
+ );
134
+ }
135
+
136
+ // Defend against models that emit orchestrator-owned sections despite
137
+ // the prompt asking them not to. Each helper is idempotent.
138
+ let cleanBody = opts.body.trim();
139
+ cleanBody = stripModelH1(cleanBody, opts.topic);
140
+ cleanBody = stripOrchestratorSections(cleanBody);
141
+ sections.push(cleanBody);
142
+
143
+ // Appendix is the orchestrator's responsibility — keeps the model from
144
+ // emitting a placeholder it doesn't have the data to fill.
145
+ if (opts.runFolder) {
146
+ sections.push(
147
+ [
148
+ "",
149
+ "## Appendix: Raw Outputs",
150
+ "",
151
+ `- Run folder: \`${opts.runFolder}/\``,
152
+ `- Worker output (if applicable): \`${opts.runFolder}/outputs/worker.md\``,
153
+ `- Reviewer output (raw): \`${opts.runFolder}/outputs/reviewer.md\``,
154
+ `- Context manifest: \`${opts.runFolder}/context-manifest.json\``,
155
+ `- Input (redacted): \`${opts.runFolder}/input.md\``,
156
+ `- Run metadata: \`${opts.runFolder}/run.json\``,
157
+ ].join("\n"),
158
+ );
159
+ }
160
+
161
+ sections.push(
162
+ [
163
+ "",
164
+ "---",
165
+ `_Generated by AlmightyGPT. Edit the human-facing sections (Human Decision, notes); the rest will be overwritten on next run._`,
166
+ ].join("\n"),
167
+ );
168
+
169
+ return sections.join("\n");
170
+ }
171
+
172
+ /**
173
+ * Strip an `# Review:` (or similar) H1 the model emitted at the top of its
174
+ * response. The prompt explicitly tells the model not to do this, but we
175
+ * defend against backslide so the file never has two H1s.
176
+ */
177
+ function stripModelH1(body: string, topic: string): string {
178
+ const lines = body.split("\n");
179
+ // Drop any leading H1 line and the blank line that may follow.
180
+ while (lines.length > 0) {
181
+ const line = lines[0]!;
182
+ if (/^#\s+/i.test(line)) {
183
+ lines.shift();
184
+ // Also drop a single immediately-following blank line.
185
+ if (lines.length > 0 && lines[0]!.trim() === "") lines.shift();
186
+ continue;
187
+ }
188
+ break;
189
+ }
190
+ // Reference topic to discourage `noUnusedParameters` complaints and
191
+ // make future logic that wants to match topic-specific H1s straightforward.
192
+ void topic;
193
+ return lines.join("\n");
194
+ }
195
+
196
+ /**
197
+ * Strip any orchestrator-owned sections the model emitted: Cost and Latency,
198
+ * and Appendix: Raw Outputs. The orchestrator emits both from real data; if
199
+ * the model emits placeholder versions, drop them so the file is clean.
200
+ *
201
+ * Sections are matched at `## ` level, case-insensitive on the title text,
202
+ * and span until the next `## ` heading or end-of-string.
203
+ */
204
+ function stripOrchestratorSections(body: string): string {
205
+ const sectionPattern =
206
+ /\n##\s+(?:Cost\s+and\s+Latency|Appendix(?::\s+Raw\s+Outputs)?)\s*\n[\s\S]*?(?=\n##\s|\n?$)/gi;
207
+ return body.replace(sectionPattern, "").trimEnd();
208
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Record a human decision on a completed review.
3
+ *
4
+ * Updates two places:
5
+ * 1. The human review file (docs/<reviewer>-reviews/<topic>.md): the
6
+ * `## Human Decision` section gets a structured body (status,
7
+ * decidedBy, decidedAt, note). Other sections are left alone.
8
+ * 2. The run's run.json: `decision` field is set with the same content.
9
+ *
10
+ * If the run.json's `reviewPath` is missing, the function falls back to
11
+ * computing `docs/<reviewer>-reviews/<topic>.md` from `topic` and the
12
+ * config's reviewsDir.
13
+ */
14
+
15
+ import { readFile, writeFile } from "node:fs/promises";
16
+ import { existsSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { writeRunMetadata } from "./folder.js";
19
+ import type {
20
+ DecisionStatus,
21
+ HumanDecision,
22
+ RunMetadata,
23
+ } from "./types.js";
24
+
25
+ export interface RecordDecisionOptions {
26
+ repoRoot: string;
27
+ /** Parsed run metadata loaded by the caller. */
28
+ meta: RunMetadata;
29
+ /** Absolute path to the run folder containing run.json. */
30
+ runFolderAbs: string;
31
+ /** Decision status. */
32
+ status: DecisionStatus;
33
+ /** Optional one-paragraph note. */
34
+ note?: string;
35
+ /** Optional decider identity (defaults to undefined). */
36
+ decidedBy?: string;
37
+ /** Override the auto-detected review file path. */
38
+ reviewPath?: string;
39
+ /**
40
+ * No-op flag preserved for forward compatibility. recordDecision only
41
+ * rewrites the "## Human Decision" section; other content is preserved,
42
+ * so a git-status safety check is not needed here. Was previously used
43
+ * to bypass an assertSafeToWrite check that proved too strict in
44
+ * practice (review files are routinely untracked right after a review
45
+ * run completes).
46
+ */
47
+ force?: boolean;
48
+ }
49
+
50
+ export interface RecordDecisionResult {
51
+ reviewPath: string;
52
+ runJsonUpdated: boolean;
53
+ decision: HumanDecision;
54
+ }
55
+
56
+ const DECISION_SECTION_HEADER = "## Human Decision";
57
+
58
+ export async function recordDecision(
59
+ opts: RecordDecisionOptions,
60
+ ): Promise<RecordDecisionResult> {
61
+ const reviewPath =
62
+ opts.reviewPath ?? opts.meta.reviewPath ?? null;
63
+ if (!reviewPath) {
64
+ throw new Error(
65
+ `Run ${opts.meta.id} has no reviewPath in run.json and no --review-path override was supplied.`,
66
+ );
67
+ }
68
+
69
+ const reviewAbs = join(opts.repoRoot, reviewPath);
70
+ if (!existsSync(reviewAbs)) {
71
+ throw new Error(
72
+ `Review file not found at ${reviewPath}. Did the review run complete successfully?`,
73
+ );
74
+ }
75
+
76
+ // No git-status safety check: recordDecision only rewrites the
77
+ // "## Human Decision" section. All other content (Worker plan summary,
78
+ // Reviewer findings, the reader's hand-written notes) is preserved
79
+ // verbatim by updateDecisionSection. Adding a dirty-file refusal here
80
+ // breaks the natural workflow where users `decide` immediately after
81
+ // a review run, before committing the freshly-written review file.
82
+
83
+ const decision: HumanDecision = {
84
+ status: opts.status,
85
+ decidedAt: new Date().toISOString(),
86
+ };
87
+ if (opts.note) decision.note = opts.note;
88
+ if (opts.decidedBy) decision.decidedBy = opts.decidedBy;
89
+
90
+ const original = await readFile(reviewAbs, "utf8");
91
+ const updated = updateDecisionSection(original, decision);
92
+ await writeFile(reviewAbs, updated, "utf8");
93
+
94
+ const updatedMeta: RunMetadata = { ...opts.meta, decision };
95
+ await writeRunMetadata(opts.runFolderAbs, updatedMeta);
96
+
97
+ return {
98
+ reviewPath,
99
+ runJsonUpdated: true,
100
+ decision,
101
+ };
102
+ }
103
+
104
+ function renderDecisionBody(d: HumanDecision): string {
105
+ const lines: string[] = [
106
+ "",
107
+ `**Status:** ${d.status}`,
108
+ `**Decided at:** ${d.decidedAt}`,
109
+ ];
110
+ if (d.decidedBy) lines.push(`**Decided by:** ${d.decidedBy}`);
111
+ if (d.note) {
112
+ lines.push("");
113
+ lines.push(d.note);
114
+ }
115
+ lines.push("");
116
+ return lines.join("\n");
117
+ }
118
+
119
+ /**
120
+ * Replace the body of the "## Human Decision" section (or append the section
121
+ * if it's missing). Everything between the header and the next `## ` (or end
122
+ * of file) is replaced with the rendered decision body.
123
+ */
124
+ function updateDecisionSection(
125
+ content: string,
126
+ decision: HumanDecision,
127
+ ): string {
128
+ const body = renderDecisionBody(decision);
129
+ const headerRegex = /^## Human Decision\s*$/m;
130
+ const match = content.match(headerRegex);
131
+
132
+ if (!match) {
133
+ // Append a new section. Make sure the file ends with a newline first.
134
+ const sep = content.endsWith("\n") ? "" : "\n";
135
+ return `${content}${sep}\n${DECISION_SECTION_HEADER}\n${body}`;
136
+ }
137
+
138
+ const headerIdx = match.index!;
139
+ const afterHeader = headerIdx + match[0].length;
140
+
141
+ // Find the next `## ` heading (or end of file).
142
+ const nextHeading = content.slice(afterHeader).search(/\n## /);
143
+ const replaceTo =
144
+ nextHeading === -1 ? content.length : afterHeader + nextHeading;
145
+
146
+ return (
147
+ content.slice(0, afterHeader) +
148
+ "\n" +
149
+ body.trimEnd() +
150
+ "\n" +
151
+ content.slice(replaceTo)
152
+ );
153
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Create and populate run folders at .almightygpt/runs/<id>/.
3
+ *
4
+ * Layout:
5
+ * <id>/
6
+ * run.json
7
+ * input.md
8
+ * context-manifest.json
9
+ * outputs/
10
+ * worker.md (when Worker stage runs — task #16)
11
+ * reviewer.md
12
+ * logs/
13
+ * orchestrator.log (later)
14
+ *
15
+ * The id format is <YYYY-MM-DD-HHmmss>-<topic-slug>-<4-char-random> to keep
16
+ * two concurrent runs in the same second from colliding.
17
+ */
18
+
19
+ import { mkdir, writeFile } from "node:fs/promises";
20
+ import { randomBytes } from "node:crypto";
21
+ import { join } from "node:path";
22
+ import { execa } from "execa";
23
+ import type { RunMetadata } from "./types.js";
24
+
25
+ export interface CreateRunFolderOptions {
26
+ repoRoot: string;
27
+ runsDir: string;
28
+ topic: string;
29
+ type: RunMetadata["type"];
30
+ }
31
+
32
+ export interface CreatedRunFolder {
33
+ /** Run id (the folder name). */
34
+ id: string;
35
+ /** Absolute path of the run folder. */
36
+ absPath: string;
37
+ /** Path relative to repo root. */
38
+ relPath: string;
39
+ }
40
+
41
+ export async function createRunFolder(
42
+ opts: CreateRunFolderOptions,
43
+ ): Promise<CreatedRunFolder> {
44
+ const timestamp = isoTimestampForId();
45
+ const topicSlug = slug(opts.topic);
46
+ const rand = randomBytes(2).toString("hex");
47
+ const id = `${timestamp}-${topicSlug}-${rand}`;
48
+
49
+ const relPath = join(opts.runsDir, id);
50
+ const absPath = join(opts.repoRoot, relPath);
51
+
52
+ await mkdir(join(absPath, "outputs"), { recursive: true });
53
+ await mkdir(join(absPath, "logs"), { recursive: true });
54
+
55
+ return { id, absPath, relPath };
56
+ }
57
+
58
+ export async function writeRunMetadata(
59
+ folderAbsPath: string,
60
+ meta: RunMetadata,
61
+ ): Promise<void> {
62
+ await writeFile(
63
+ join(folderAbsPath, "run.json"),
64
+ JSON.stringify(meta, null, 2) + "\n",
65
+ "utf8",
66
+ );
67
+ }
68
+
69
+ export async function writeRunInput(
70
+ folderAbsPath: string,
71
+ content: string,
72
+ ): Promise<void> {
73
+ await writeFile(join(folderAbsPath, "input.md"), content, "utf8");
74
+ }
75
+
76
+ export async function writeAgentOutput(
77
+ folderAbsPath: string,
78
+ role: "worker" | "reviewer" | "judge",
79
+ content: string,
80
+ ): Promise<void> {
81
+ await writeFile(
82
+ join(folderAbsPath, "outputs", `${role}.md`),
83
+ content,
84
+ "utf8",
85
+ );
86
+ }
87
+
88
+ export async function collectGitContext(repoRoot: string): Promise<{
89
+ branch?: string;
90
+ commit?: string;
91
+ dirty: boolean;
92
+ }> {
93
+ const branch = await safeGit(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
94
+ const commit = await safeGit(repoRoot, ["rev-parse", "HEAD"]);
95
+ const dirtyOut = await safeGit(repoRoot, ["status", "--short"]);
96
+ return {
97
+ ...(branch ? { branch } : {}),
98
+ ...(commit ? { commit } : {}),
99
+ dirty: dirtyOut !== undefined && dirtyOut.trim().length > 0,
100
+ };
101
+ }
102
+
103
+ async function safeGit(
104
+ repoRoot: string,
105
+ args: string[],
106
+ ): Promise<string | undefined> {
107
+ try {
108
+ const { stdout, exitCode } = await execa("git", args, {
109
+ cwd: repoRoot,
110
+ reject: false,
111
+ stripFinalNewline: true,
112
+ });
113
+ if (exitCode !== 0) return undefined;
114
+ return stdout || undefined;
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ }
119
+
120
+ function isoTimestampForId(): string {
121
+ // YYYY-MM-DD-HHmmss in UTC. Filesystem-safe.
122
+ const d = new Date();
123
+ const pad = (n: number): string => n.toString().padStart(2, "0");
124
+ return (
125
+ `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}` +
126
+ `-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
127
+ );
128
+ }
129
+
130
+ function slug(s: string): string {
131
+ return s
132
+ .trim()
133
+ .toLowerCase()
134
+ .replace(/[^a-z0-9._-]+/g, "-")
135
+ .replace(/^-+|-+$/g, "")
136
+ .slice(0, 40) || "run";
137
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * List existing runs from `.almightygpt/runs/`.
3
+ *
4
+ * Reads every `<id>/run.json`, validates it minimally, and returns a list
5
+ * sorted newest-first by `createdAt`. Used by `almightygpt runs list` and
6
+ * `almightygpt runs latest`.
7
+ *
8
+ * Bad/missing run.json entries are surfaced as `errors` instead of
9
+ * throwing, so a single corrupt run doesn't break the listing.
10
+ */
11
+
12
+ import { readdir, readFile, stat } from "node:fs/promises";
13
+ import { existsSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import type { RunMetadata, RunStatus, RunType } from "./types.js";
16
+
17
+ export interface RunSummary {
18
+ id: string;
19
+ type: RunType;
20
+ topic: string;
21
+ status: RunStatus;
22
+ createdAt: string;
23
+ finishedAt?: string;
24
+ totalCostUsd: number;
25
+ totalTokens: number;
26
+ reviewer?: string;
27
+ worker?: string;
28
+ reviewPath?: string;
29
+ runFolder: string;
30
+ decision?: RunMetadata["decision"];
31
+ error?: RunMetadata["error"];
32
+ }
33
+
34
+ export interface ListRunsResult {
35
+ runs: RunSummary[];
36
+ /** Run folders that couldn't be parsed (path → reason). */
37
+ errors: { path: string; reason: string }[];
38
+ }
39
+
40
+ export interface ListRunsOptions {
41
+ repoRoot: string;
42
+ runsDir: string;
43
+ /** Cap the number of results. Most recent first. */
44
+ limit?: number;
45
+ }
46
+
47
+ export async function listRuns(opts: ListRunsOptions): Promise<ListRunsResult> {
48
+ const runsAbs = join(opts.repoRoot, opts.runsDir);
49
+ if (!existsSync(runsAbs)) {
50
+ return { runs: [], errors: [] };
51
+ }
52
+
53
+ const entries = await readdir(runsAbs, { withFileTypes: true });
54
+ const folders = entries.filter((e) => e.isDirectory()).map((e) => e.name);
55
+
56
+ const runs: RunSummary[] = [];
57
+ const errors: { path: string; reason: string }[] = [];
58
+
59
+ for (const folder of folders) {
60
+ const runJsonPath = join(runsAbs, folder, "run.json");
61
+ if (!existsSync(runJsonPath)) {
62
+ errors.push({ path: folder, reason: "missing run.json" });
63
+ continue;
64
+ }
65
+ try {
66
+ const raw = await readFile(runJsonPath, "utf8");
67
+ const meta = JSON.parse(raw) as RunMetadata;
68
+ runs.push(toSummary(meta, opts.runsDir));
69
+ } catch (err) {
70
+ errors.push({
71
+ path: folder,
72
+ reason: err instanceof Error ? err.message : String(err),
73
+ });
74
+ }
75
+ }
76
+
77
+ runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
78
+
79
+ if (opts.limit && runs.length > opts.limit) {
80
+ runs.length = opts.limit;
81
+ }
82
+
83
+ return { runs, errors };
84
+ }
85
+
86
+ export async function findRunById(
87
+ repoRoot: string,
88
+ runsDir: string,
89
+ runId: string,
90
+ ): Promise<{ meta: RunMetadata; absPath: string } | null> {
91
+ const absPath = join(repoRoot, runsDir, runId);
92
+ const runJsonPath = join(absPath, "run.json");
93
+ if (!existsSync(runJsonPath)) return null;
94
+ try {
95
+ const raw = await readFile(runJsonPath, "utf8");
96
+ const meta = JSON.parse(raw) as RunMetadata;
97
+ return { meta, absPath };
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Resolve "latest" to a concrete run id. Returns null if no runs exist.
105
+ */
106
+ export async function findLatestRun(
107
+ repoRoot: string,
108
+ runsDir: string,
109
+ ): Promise<{ meta: RunMetadata; absPath: string } | null> {
110
+ const { runs } = await listRuns({ repoRoot, runsDir, limit: 1 });
111
+ const first = runs[0];
112
+ if (!first) return null;
113
+ return findRunById(repoRoot, runsDir, first.id);
114
+ }
115
+
116
+ function toSummary(meta: RunMetadata, runsDir: string): RunSummary {
117
+ const reviewerMetric = meta.metrics.find((m) => m.role === "reviewer");
118
+ const workerMetric = meta.metrics.find((m) => m.role === "worker");
119
+ const summary: RunSummary = {
120
+ id: meta.id,
121
+ type: meta.type,
122
+ topic: meta.topic,
123
+ status: meta.status,
124
+ createdAt: meta.createdAt,
125
+ totalCostUsd: meta.totals.costUsd,
126
+ totalTokens: meta.totals.tokensIn + meta.totals.tokensOut,
127
+ runFolder: join(runsDir, meta.id),
128
+ };
129
+ if (meta.finishedAt) summary.finishedAt = meta.finishedAt;
130
+ if (reviewerMetric) summary.reviewer = reviewerMetric.agent;
131
+ if (workerMetric) summary.worker = workerMetric.agent;
132
+ if (meta.reviewPath) summary.reviewPath = meta.reviewPath;
133
+ if (meta.decision) summary.decision = meta.decision;
134
+ if (meta.error) summary.error = meta.error;
135
+ // stat to make sure the folder is real — but skip if we can't stat
136
+ void stat;
137
+ return summary;
138
+ }
139
+
140
+ /**
141
+ * Format a duration in seconds for human display.
142
+ */
143
+ export function formatDuration(fromIso: string, toIso?: string): string {
144
+ if (!toIso) return "—";
145
+ const ms = Date.parse(toIso) - Date.parse(fromIso);
146
+ if (Number.isNaN(ms) || ms < 0) return "—";
147
+ if (ms < 1000) return `${ms}ms`;
148
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
149
+ const min = Math.floor(ms / 60_000);
150
+ const sec = Math.floor((ms % 60_000) / 1000);
151
+ return `${min}m${sec}s`;
152
+ }