@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.
- package/dist/adapters/claude.d.ts +31 -0
- package/dist/adapters/claude.d.ts.map +1 -0
- package/dist/adapters/claude.js +90 -0
- package/dist/adapters/claude.js.map +1 -0
- package/dist/adapters/gemini.d.ts +42 -0
- package/dist/adapters/gemini.d.ts.map +1 -0
- package/dist/adapters/gemini.js +133 -0
- package/dist/adapters/gemini.js.map +1 -0
- package/dist/adapters/index.d.ts +16 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +15 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/mock.d.ts +23 -0
- package/dist/adapters/mock.d.ts.map +1 -0
- package/dist/adapters/mock.js +107 -0
- package/dist/adapters/mock.js.map +1 -0
- package/dist/adapters/openai.d.ts +38 -0
- package/dist/adapters/openai.d.ts.map +1 -0
- package/dist/adapters/openai.js +105 -0
- package/dist/adapters/openai.js.map +1 -0
- package/dist/adapters/types.d.ts +65 -0
- package/dist/adapters/types.d.ts.map +1 -0
- package/dist/adapters/types.js +26 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/config/load.d.ts +15 -0
- package/dist/config/load.d.ts.map +1 -0
- package/dist/config/load.js +46 -0
- package/dist/config/load.js.map +1 -0
- package/dist/config/schema.d.ts +260 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +58 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/context/manifest.d.ts +58 -0
- package/dist/context/manifest.d.ts.map +1 -0
- package/dist/context/manifest.js +49 -0
- package/dist/context/manifest.js.map +1 -0
- package/dist/context/redact.d.ts +26 -0
- package/dist/context/redact.d.ts.map +1 -0
- package/dist/context/redact.js +67 -0
- package/dist/context/redact.js.map +1 -0
- package/dist/git/status.d.ts +48 -0
- package/dist/git/status.d.ts.map +1 -0
- package/dist/git/status.js +79 -0
- package/dist/git/status.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/index.js.map +1 -0
- package/dist/review/budget.d.ts +46 -0
- package/dist/review/budget.d.ts.map +1 -0
- package/dist/review/budget.js +83 -0
- package/dist/review/budget.js.map +1 -0
- package/dist/review/diff.d.ts +21 -0
- package/dist/review/diff.d.ts.map +1 -0
- package/dist/review/diff.js +55 -0
- package/dist/review/diff.js.map +1 -0
- package/dist/review/events.d.ts +76 -0
- package/dist/review/events.d.ts.map +1 -0
- package/dist/review/events.js +13 -0
- package/dist/review/events.js.map +1 -0
- package/dist/review/memory.d.ts +23 -0
- package/dist/review/memory.d.ts.map +1 -0
- package/dist/review/memory.js +42 -0
- package/dist/review/memory.js.map +1 -0
- package/dist/review/prompts.d.ts +34 -0
- package/dist/review/prompts.d.ts.map +1 -0
- package/dist/review/prompts.js +174 -0
- package/dist/review/prompts.js.map +1 -0
- package/dist/review/run-diff-review.d.ts +52 -0
- package/dist/review/run-diff-review.d.ts.map +1 -0
- package/dist/review/run-diff-review.js +258 -0
- package/dist/review/run-diff-review.js.map +1 -0
- package/dist/review/run-worker-reviewer.d.ts +72 -0
- package/dist/review/run-worker-reviewer.d.ts.map +1 -0
- package/dist/review/run-worker-reviewer.js +407 -0
- package/dist/review/run-worker-reviewer.js.map +1 -0
- package/dist/review/write.d.ts +44 -0
- package/dist/review/write.d.ts.map +1 -0
- package/dist/review/write.js +152 -0
- package/dist/review/write.js.map +1 -0
- package/dist/runs/decide.d.ts +45 -0
- package/dist/runs/decide.d.ts.map +1 -0
- package/dist/runs/decide.js +93 -0
- package/dist/runs/decide.js.map +1 -0
- package/dist/runs/folder.d.ts +42 -0
- package/dist/runs/folder.d.ts.map +1 -0
- package/dist/runs/folder.js +82 -0
- package/dist/runs/folder.js.map +1 -0
- package/dist/runs/list.d.ts +58 -0
- package/dist/runs/list.d.ts.map +1 -0
- package/dist/runs/list.js +117 -0
- package/dist/runs/list.js.map +1 -0
- package/dist/runs/types.d.ts +96 -0
- package/dist/runs/types.d.ts.map +1 -0
- package/dist/runs/types.js +13 -0
- package/dist/runs/types.js.map +1 -0
- package/dist/templates/install.d.ts +49 -0
- package/dist/templates/install.d.ts.map +1 -0
- package/dist/templates/install.js +154 -0
- package/dist/templates/install.js.map +1 -0
- package/package.json +34 -0
- package/src/adapters/claude.ts +133 -0
- package/src/adapters/gemini.ts +183 -0
- package/src/adapters/index.ts +21 -0
- package/src/adapters/mock.ts +125 -0
- package/src/adapters/openai.ts +150 -0
- package/src/adapters/types.ts +73 -0
- package/src/config/load.ts +61 -0
- package/src/config/schema.ts +64 -0
- package/src/context/manifest.ts +94 -0
- package/src/context/redact.ts +93 -0
- package/src/git/status.ts +108 -0
- package/src/index.ts +127 -0
- package/src/review/budget.ts +116 -0
- package/src/review/diff.ts +85 -0
- package/src/review/events.ts +86 -0
- package/src/review/memory.ts +57 -0
- package/src/review/prompts.ts +208 -0
- package/src/review/run-diff-review.ts +353 -0
- package/src/review/run-worker-reviewer.ts +528 -0
- package/src/review/write.ts +208 -0
- package/src/runs/decide.ts +153 -0
- package/src/runs/folder.ts +137 -0
- package/src/runs/list.ts +152 -0
- package/src/runs/types.ts +98 -0
- 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
|
+
}
|
package/src/runs/list.ts
ADDED
|
@@ -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
|
+
}
|