@syntax-syllogism/aloop 0.7.0 → 0.8.1
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/CHANGELOG.md +21 -7
- package/README.md +21 -6
- package/bin/eval.mjs +58 -0
- package/bin/loop.mjs +31 -2
- package/package.json +9 -3
- package/presets/work-item/README.md +9 -7
- package/prompts/fix-gate.md +17 -0
- package/src/adapters.mjs +17 -4
- package/src/backends/gitlab.mjs +5 -3
- package/src/command.mjs +5 -10
- package/src/config.mjs +7 -2
- package/src/eval.mjs +217 -0
- package/src/index.mjs +7 -0
- package/src/manifest.mjs +4 -0
- package/src/operations.mjs +120 -7
- package/src/pipeline.mjs +62 -19
- package/src/publish.mjs +4 -12
- package/src/reporter.mjs +37 -1
- package/src/runner.mjs +153 -21
- package/src/state.mjs +5 -1
- package/src/tui.mjs +171 -0
- package/src/types.d.ts +238 -0
- package/src/verdict.mjs +9 -1
package/src/eval.mjs
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/** @typedef {import('./types.js').Summary} Summary */
|
|
2
|
+
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { promisify } from 'node:util';
|
|
8
|
+
import { computeRunMetrics, readRunManifest } from './metrics.mjs';
|
|
9
|
+
import { runLoop } from './pipeline.mjs';
|
|
10
|
+
|
|
11
|
+
const exec = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A task corpus entry: a self-contained repo fixture plus a task description
|
|
15
|
+
* and (optionally) a held-out check that never appears in the visible gate.
|
|
16
|
+
* Escaped defects are the gap between what the gate/review approved and what
|
|
17
|
+
* the held-out check demands.
|
|
18
|
+
*
|
|
19
|
+
* @typedef {Object} EvalTask
|
|
20
|
+
* @property {string} id
|
|
21
|
+
* @property {Record<string, string>} [files] - initial repo contents, relative to the fixture root
|
|
22
|
+
* @property {string} [task] - inline task text written to the run's task file
|
|
23
|
+
* @property {string[]} [gate] - visible gate commands (what the loop enforces)
|
|
24
|
+
* @property {string[]} [heldOutCheck] - hidden verification commands run after approval
|
|
25
|
+
* @property {string[]} [phases] - phase list override (default: implement, gate, review)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A matrix cell: a model/engine/config to run every task under. `configSource`
|
|
30
|
+
* gives full control over the generated `loop.config.mjs` (needed for custom
|
|
31
|
+
* adapters, e.g. a fake engine in tests); when omitted, a minimal config is
|
|
32
|
+
* generated from `engine`, `phases`, and `configExtra`.
|
|
33
|
+
*
|
|
34
|
+
* @typedef {Object} EvalConfig
|
|
35
|
+
* @property {string} name
|
|
36
|
+
* @property {string} [engine]
|
|
37
|
+
* @property {string[]} [phases]
|
|
38
|
+
* @property {Record<string, unknown>} [configExtra]
|
|
39
|
+
* @property {((task: EvalTask) => string)|string} [configSource]
|
|
40
|
+
* @property {Record<string, unknown>} [runArgs]
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
async function git(cwd, ...args) {
|
|
44
|
+
return (await exec('git', args, { cwd })).stdout.trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function writeTaskFiles(root, files) {
|
|
48
|
+
for (const [relPath, contents] of Object.entries(files ?? {})) {
|
|
49
|
+
const full = join(root, relPath);
|
|
50
|
+
await mkdir(dirname(full), { recursive: true });
|
|
51
|
+
await writeFile(full, contents);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function runShellChecks(cwd, commands) {
|
|
56
|
+
for (const command of commands ?? []) {
|
|
57
|
+
try {
|
|
58
|
+
await exec('sh', ['-c', command], { cwd });
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function defaultConfigSource(task, config) {
|
|
67
|
+
const body = {
|
|
68
|
+
gate: task.gate ?? [],
|
|
69
|
+
worktrees: false,
|
|
70
|
+
phases: config.phases ?? task.phases ?? ['implement', 'gate', 'review'],
|
|
71
|
+
engines: { default: config.engine ?? 'claude' },
|
|
72
|
+
...(config.configExtra ?? {}),
|
|
73
|
+
};
|
|
74
|
+
return `export default ${JSON.stringify(body, null, 2)};\n`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveConfigSource(task, config) {
|
|
78
|
+
if (typeof config.configSource === 'function') return config.configSource(task);
|
|
79
|
+
if (typeof config.configSource === 'string') return config.configSource;
|
|
80
|
+
return defaultConfigSource(task, config);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Run one task through the loop under one config, in a throwaway repo, and
|
|
85
|
+
* report completion, escaped defects (via the held-out check), convergence,
|
|
86
|
+
* cost, and time — reading cost/convergence from the run's own manifest so the
|
|
87
|
+
* numbers are exactly what {@link computeRunMetrics} would report for a single
|
|
88
|
+
* real run.
|
|
89
|
+
*
|
|
90
|
+
* @param {{ task: EvalTask, config: EvalConfig, baseDir?: string }} options
|
|
91
|
+
*/
|
|
92
|
+
export async function runEvalCell({ task, config, baseDir }) {
|
|
93
|
+
const root = await mkdtemp(join(baseDir ?? tmpdir(), 'aloop-eval-'));
|
|
94
|
+
await git(root, 'init', '--initial-branch=master');
|
|
95
|
+
await git(root, 'config', 'user.email', 'eval@example.com');
|
|
96
|
+
await git(root, 'config', 'user.name', 'Eval Harness');
|
|
97
|
+
await writeTaskFiles(root, task.files);
|
|
98
|
+
// The loop writes its run state (manifest, logs, lock files) under .loop/
|
|
99
|
+
// inside the repo it's driving; without ignoring it, that bookkeeping shows
|
|
100
|
+
// up as untracked changes and trips the implement/review clean-tree check.
|
|
101
|
+
if (!task.files?.['.gitignore']) await writeFile(join(root, '.gitignore'), '.loop/\n');
|
|
102
|
+
await git(root, 'add', '.');
|
|
103
|
+
await git(root, 'commit', '-m', 'chore: init eval fixture');
|
|
104
|
+
|
|
105
|
+
await writeFile(join(root, 'loop.config.mjs'), resolveConfigSource(task, config));
|
|
106
|
+
const taskFile = join(root, 'TASK.md');
|
|
107
|
+
await writeFile(taskFile, task.task ?? '');
|
|
108
|
+
|
|
109
|
+
let summary = null;
|
|
110
|
+
let error = null;
|
|
111
|
+
try {
|
|
112
|
+
summary = await runLoop({ args: { taskFile, cwd: root, name: task.id, yes: true, ...(config.runArgs ?? {}) } });
|
|
113
|
+
} catch (caught) {
|
|
114
|
+
error = caught;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const completed = Boolean(summary && summary.stalled === null);
|
|
118
|
+
const manifest = summary ? await readRunManifest(summary.runDir) : null;
|
|
119
|
+
const metrics = manifest ? computeRunMetrics(manifest) : null;
|
|
120
|
+
|
|
121
|
+
let escapedDefect = null;
|
|
122
|
+
if (completed && task.heldOutCheck?.length) {
|
|
123
|
+
escapedDefect = !(await runShellChecks(summary.worktree, task.heldOutCheck));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const approvedSha = completed ? await git(summary.worktree, 'rev-parse', 'HEAD').catch(() => null) : null;
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
task: task.id,
|
|
130
|
+
config: config.name,
|
|
131
|
+
completed,
|
|
132
|
+
stalled: summary?.stalled ?? null,
|
|
133
|
+
escapedDefect,
|
|
134
|
+
roundsToConverge: metrics?.convergence.roundsToConverge ?? null,
|
|
135
|
+
cost: metrics?.total.cost ?? null,
|
|
136
|
+
durationMs: metrics?.total.durationMs ?? null,
|
|
137
|
+
approvedSha,
|
|
138
|
+
runDir: summary?.runDir ?? null,
|
|
139
|
+
snapshotPath: summary ? join(summary.runDir, 'snapshot.json') : null,
|
|
140
|
+
error: error ? error.message : null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Run every task × config cell in the matrix.
|
|
146
|
+
*
|
|
147
|
+
* @param {{ corpus: EvalTask[], matrix: EvalConfig[], baseDir?: string }} options
|
|
148
|
+
*/
|
|
149
|
+
export async function runEval({ corpus, matrix, baseDir }) {
|
|
150
|
+
const results = [];
|
|
151
|
+
for (const task of corpus) {
|
|
152
|
+
for (const config of matrix) {
|
|
153
|
+
results.push(await runEvalCell({ task, config, baseDir }));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return results;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Aggregate per-config totals across every task cell. */
|
|
160
|
+
export function summarizeEval(results) {
|
|
161
|
+
const byConfig = new Map();
|
|
162
|
+
for (const cell of results) {
|
|
163
|
+
if (!byConfig.has(cell.config)) byConfig.set(cell.config, []);
|
|
164
|
+
byConfig.get(cell.config).push(cell);
|
|
165
|
+
}
|
|
166
|
+
return [...byConfig.entries()].map(([config, cells]) => {
|
|
167
|
+
const escapedChecked = cells.filter((cell) => cell.escapedDefect !== null);
|
|
168
|
+
const escaped = escapedChecked.filter((cell) => cell.escapedDefect).length;
|
|
169
|
+
const converged = cells.filter((cell) => cell.roundsToConverge !== null);
|
|
170
|
+
const costKnown = cells.length > 0 && cells.every((cell) => cell.cost !== null);
|
|
171
|
+
return {
|
|
172
|
+
config,
|
|
173
|
+
cells: cells.length,
|
|
174
|
+
completionRate: cells.length ? cells.filter((cell) => cell.completed).length / cells.length : null,
|
|
175
|
+
escapedDefectRate: escapedChecked.length ? escaped / escapedChecked.length : null,
|
|
176
|
+
avgRoundsToConverge: converged.length
|
|
177
|
+
? converged.reduce((total, cell) => total + cell.roundsToConverge, 0) / converged.length
|
|
178
|
+
: null,
|
|
179
|
+
totalCost: costKnown ? cells.reduce((total, cell) => total + cell.cost, 0) : null,
|
|
180
|
+
totalDurationMs: cells.reduce((total, cell) => total + (cell.durationMs ?? 0), 0),
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function fmt(value) {
|
|
186
|
+
return value === null || value === undefined ? 'unknown' : String(value);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Render per-cell results as a tab-separated table. */
|
|
190
|
+
export function formatEvalTable(results) {
|
|
191
|
+
const header = ['TASK', 'CONFIG', 'COMPLETED', 'ESCAPED_DEFECT', 'ROUNDS', 'COST', 'DURATION_MS'];
|
|
192
|
+
const rows = results.map((cell) => [
|
|
193
|
+
cell.task,
|
|
194
|
+
cell.config,
|
|
195
|
+
String(cell.completed),
|
|
196
|
+
cell.escapedDefect === null ? 'n/a' : String(cell.escapedDefect),
|
|
197
|
+
fmt(cell.roundsToConverge),
|
|
198
|
+
fmt(cell.cost),
|
|
199
|
+
fmt(cell.durationMs),
|
|
200
|
+
]);
|
|
201
|
+
return [header, ...rows].map((row) => row.join('\t')).join('\n');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Render per-config aggregate summaries as a tab-separated table. */
|
|
205
|
+
export function formatEvalSummaryTable(summaries) {
|
|
206
|
+
const header = ['CONFIG', 'CELLS', 'COMPLETION_RATE', 'ESCAPED_DEFECT_RATE', 'AVG_ROUNDS', 'TOTAL_COST', 'TOTAL_DURATION_MS'];
|
|
207
|
+
const rows = summaries.map((summary) => [
|
|
208
|
+
summary.config,
|
|
209
|
+
String(summary.cells),
|
|
210
|
+
fmt(summary.completionRate),
|
|
211
|
+
fmt(summary.escapedDefectRate),
|
|
212
|
+
fmt(summary.avgRoundsToConverge),
|
|
213
|
+
fmt(summary.totalCost),
|
|
214
|
+
String(summary.totalDurationMs),
|
|
215
|
+
]);
|
|
216
|
+
return [header, ...rows].map((row) => row.join('\t')).join('\n');
|
|
217
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -23,5 +23,12 @@ export {
|
|
|
23
23
|
cleanRuns,
|
|
24
24
|
doctor,
|
|
25
25
|
} from './operations.mjs';
|
|
26
|
+
export {
|
|
27
|
+
runEval,
|
|
28
|
+
runEvalCell,
|
|
29
|
+
summarizeEval,
|
|
30
|
+
formatEvalTable,
|
|
31
|
+
formatEvalSummaryTable,
|
|
32
|
+
} from './eval.mjs';
|
|
26
33
|
export { githubBackend } from './publish.mjs';
|
|
27
34
|
export { gitlabBackend, glabTransport, parseGitLabRemoteUrl } from './backends/gitlab.mjs';
|
package/src/manifest.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** @typedef {import('./types.js').ManifestEntry} ManifestEntry */
|
|
2
|
+
|
|
1
3
|
import { createHash } from 'node:crypto';
|
|
2
4
|
import { rename, writeFile } from 'node:fs/promises';
|
|
3
5
|
import { join } from 'node:path';
|
|
@@ -40,10 +42,12 @@ export function hashConfig(config) {
|
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export class Manifest {
|
|
45
|
+
/** @param {ManifestEntry[]} [entries] */
|
|
43
46
|
constructor(entries = []) {
|
|
44
47
|
this.entries = [...entries];
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
/** @param {ManifestEntry} entry @returns {ManifestEntry} */
|
|
47
51
|
append(entry) {
|
|
48
52
|
const now = new Date().toISOString();
|
|
49
53
|
const recorded = {
|
package/src/operations.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { access, readFile, readdir, rm, stat } from 'node:fs/promises';
|
|
1
|
+
import { access, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import { join, resolve } from 'node:path';
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
4
4
|
import { pathToFileURL } from 'node:url';
|
|
5
5
|
import { adapterFor, agentForPhase } from './adapters.mjs';
|
|
6
6
|
import { runCommand, signalProcessGroup } from './command.mjs';
|
|
@@ -8,9 +8,44 @@ import { defaults, loadConfig, loadRunsDir } from './config.mjs';
|
|
|
8
8
|
import { GitFacade } from './git.mjs';
|
|
9
9
|
import { computeAggregateMetrics, computeRunMetrics, readRunManifests } from './metrics.mjs';
|
|
10
10
|
import { RunState, slugFor } from './state.mjs';
|
|
11
|
+
import { packagePrompts } from './prompts.mjs';
|
|
11
12
|
|
|
12
13
|
const PULL_REQUEST_URL = /https?:\/\/[^\s"'`<>]+(?:\/pull\/\d+|\/merge_requests\/\d+)[^\s"'`<>]*/i;
|
|
13
14
|
|
|
15
|
+
const packagePresets = join(dirname(packagePrompts), 'presets');
|
|
16
|
+
|
|
17
|
+
const CONFIG_HEADER = `// aloop configuration — see docs/loop.md for the full reference.
|
|
18
|
+
//
|
|
19
|
+
// REVIEW THESE before your first run:
|
|
20
|
+
// baseBranch the branch you merge into (for example, 'main' or 'master')
|
|
21
|
+
// remote set this when your repository has more than one remote
|
|
22
|
+
// engines choose the agent that runs each phase
|
|
23
|
+
//
|
|
24
|
+
// SAFE TO CHANGE anytime:
|
|
25
|
+
// phases the pipeline stages and their order
|
|
26
|
+
// gate commands that must pass before review
|
|
27
|
+
// branchPrefix, maxRounds, timeoutMs
|
|
28
|
+
//
|
|
29
|
+
// Prompts live in .loop/prompts/ — edit any *.md there to change what each
|
|
30
|
+
// phase tells the agent. Delete a file to fall back to the packaged default.
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
const CONFIG_FOOTER = '// happy agentic looping :)\n';
|
|
34
|
+
|
|
35
|
+
const EMBEDDED_STARTER = `export default {
|
|
36
|
+
baseBranch: 'master', // <- set to your default branch
|
|
37
|
+
branchPrefix: 'feat/',
|
|
38
|
+
remote: null, // <- set this if you have more than one remote
|
|
39
|
+
engines: {
|
|
40
|
+
default: { name: 'claude' },
|
|
41
|
+
// review: { name: 'codex' }, // optional: use a different reviewer
|
|
42
|
+
},
|
|
43
|
+
phases: ['implement', 'docs', 'gate', 'review', 'address', 'pr-description', 'publish'],
|
|
44
|
+
gate: [], // <- e.g. ['npm test'] before review
|
|
45
|
+
maxRounds: 3,
|
|
46
|
+
};
|
|
47
|
+
`;
|
|
48
|
+
|
|
14
49
|
async function readJson(path, { optional = true } = {}) {
|
|
15
50
|
try {
|
|
16
51
|
return JSON.parse(await readFile(path, 'utf8'));
|
|
@@ -417,7 +452,8 @@ async function operationalContext(cwd, configPath, { validate = false } = {}) {
|
|
|
417
452
|
return { config, repoRoot, rootGit, runsDir: resolve(repoRoot, config.runsDir) };
|
|
418
453
|
}
|
|
419
454
|
|
|
420
|
-
export async function listRuns(
|
|
455
|
+
export async function listRuns(options = {}) {
|
|
456
|
+
const { cwd = process.cwd(), configPath } = /** @type {any} */ (options);
|
|
421
457
|
const context = await operationalContext(cwd, configPath);
|
|
422
458
|
let entries;
|
|
423
459
|
try {
|
|
@@ -434,7 +470,8 @@ export async function listRuns({ cwd = process.cwd(), configPath } = {}) {
|
|
|
434
470
|
return runs.sort((a, b) => (b.updatedAt ?? b.startedAt ?? '').localeCompare(a.updatedAt ?? a.startedAt ?? ''));
|
|
435
471
|
}
|
|
436
472
|
|
|
437
|
-
export async function getRun(name,
|
|
473
|
+
export async function getRun(name, options = {}) {
|
|
474
|
+
const { cwd = process.cwd(), configPath } = /** @type {any} */ (options);
|
|
438
475
|
const slug = slugFor(name);
|
|
439
476
|
if (!slug) throw new Error('A run name is required.');
|
|
440
477
|
const context = await operationalContext(cwd, configPath);
|
|
@@ -453,7 +490,8 @@ export async function inspectRun(name, options = {}) {
|
|
|
453
490
|
};
|
|
454
491
|
}
|
|
455
492
|
|
|
456
|
-
export async function cancelRun(name,
|
|
493
|
+
export async function cancelRun(name, options = {}) {
|
|
494
|
+
const { cwd = process.cwd(), configPath } = /** @type {any} */ (options);
|
|
457
495
|
const context = await operationalContext(cwd, configPath);
|
|
458
496
|
const slug = slugFor(name);
|
|
459
497
|
if (!slug) throw new Error('A run name is required.');
|
|
@@ -570,7 +608,8 @@ async function stopProcesses(targets) {
|
|
|
570
608
|
return waitForProcessesToStop(watched, 5000);
|
|
571
609
|
}
|
|
572
610
|
|
|
573
|
-
export async function cleanRuns(
|
|
611
|
+
export async function cleanRuns(options = {}) {
|
|
612
|
+
const { cwd = process.cwd(), configPath, olderThanDays = 30, dryRun = false, yes = false } = /** @type {any} */ (options);
|
|
574
613
|
const context = await operationalContext(cwd, configPath);
|
|
575
614
|
const runs = await listRuns({ cwd, configPath });
|
|
576
615
|
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
|
|
@@ -603,6 +642,79 @@ export async function cleanRuns({ cwd = process.cwd(), configPath, olderThanDays
|
|
|
603
642
|
};
|
|
604
643
|
}
|
|
605
644
|
|
|
645
|
+
async function promptFilesIn(directory) {
|
|
646
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
647
|
+
return entries
|
|
648
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
|
649
|
+
.map((entry) => entry.name)
|
|
650
|
+
.sort();
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function bundledPresets() {
|
|
654
|
+
const entries = await readdir(packagePresets, { withFileTypes: true });
|
|
655
|
+
const presets = new Map();
|
|
656
|
+
for (const entry of entries) {
|
|
657
|
+
if (!entry.isDirectory()) continue;
|
|
658
|
+
const root = join(packagePresets, entry.name);
|
|
659
|
+
if (await pathExists(join(root, 'loop.config.mjs')) && await isDirectory(join(root, 'prompts'))) {
|
|
660
|
+
presets.set(entry.name, root);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return presets;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function resolvePresetRoot(name) {
|
|
667
|
+
if (typeof name !== 'string' || !name.trim()) throw new Error('--preset requires a name.');
|
|
668
|
+
const presets = await bundledPresets();
|
|
669
|
+
const root = presets.get(name);
|
|
670
|
+
if (root) return root;
|
|
671
|
+
const available = [...presets.keys()].sort().join(', ') || '(none)';
|
|
672
|
+
throw new Error(`Unknown preset "${name}". Available: ${available}.`);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function renderInitConfig(presetRoot) {
|
|
676
|
+
const body = presetRoot
|
|
677
|
+
? await readFile(join(presetRoot, 'loop.config.mjs'), 'utf8')
|
|
678
|
+
: EMBEDDED_STARTER;
|
|
679
|
+
return `${CONFIG_HEADER}\n${body.trimEnd()}\n\n${CONFIG_FOOTER}`;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Scaffold a self-contained aloop configuration and editable prompt overrides.
|
|
684
|
+
*/
|
|
685
|
+
export async function init({ cwd = process.cwd(), preset = null, force = false } = {}) {
|
|
686
|
+
const presetRoot = preset === null || preset === undefined ? null : await resolvePresetRoot(preset);
|
|
687
|
+
const promptTargetDir = join(cwd, '.loop', 'prompts');
|
|
688
|
+
const promptSources = [packagePrompts, ...(presetRoot ? [join(presetRoot, 'prompts')] : [])];
|
|
689
|
+
const prompts = new Map();
|
|
690
|
+
for (const sourceDir of promptSources) {
|
|
691
|
+
for (const name of await promptFilesIn(sourceDir)) prompts.set(name, join(sourceDir, name));
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const plan = /** @type {any[]} */ ([
|
|
695
|
+
{ target: join(cwd, 'loop.config.mjs'), kind: 'config' },
|
|
696
|
+
...[...prompts.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([name, source]) => ({
|
|
697
|
+
target: join(promptTargetDir, name), source, kind: 'prompt',
|
|
698
|
+
})),
|
|
699
|
+
]);
|
|
700
|
+
const existing = [];
|
|
701
|
+
for (const item of plan) {
|
|
702
|
+
if (await pathExists(item.target)) existing.push(relative(cwd, item.target));
|
|
703
|
+
}
|
|
704
|
+
if (existing.length && !force) {
|
|
705
|
+
const error = new Error(`Refusing to overwrite existing files:\n ${existing.join('\n ')}\nRe-run with --force to overwrite.`);
|
|
706
|
+
error.command = true;
|
|
707
|
+
throw error;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
await mkdir(promptTargetDir, { recursive: true });
|
|
711
|
+
for (const item of plan) {
|
|
712
|
+
if (item.kind === 'config') await writeFile(item.target, await renderInitConfig(presetRoot), 'utf8');
|
|
713
|
+
else await copyFile(item.source, item.target);
|
|
714
|
+
}
|
|
715
|
+
return { preset: presetRoot ? preset : null, created: plan.map((item) => relative(cwd, item.target)), skipped: [] };
|
|
716
|
+
}
|
|
717
|
+
|
|
606
718
|
async function checkCommand(command, args, cwd) {
|
|
607
719
|
try {
|
|
608
720
|
await runCommand(command, args, { cwd });
|
|
@@ -612,7 +724,8 @@ async function checkCommand(command, args, cwd) {
|
|
|
612
724
|
}
|
|
613
725
|
}
|
|
614
726
|
|
|
615
|
-
export async function doctor(
|
|
727
|
+
export async function doctor(options = {}) {
|
|
728
|
+
const { cwd = process.cwd(), configPath } = /** @type {any} */ (options);
|
|
616
729
|
const checks = [];
|
|
617
730
|
let context;
|
|
618
731
|
try {
|
package/src/pipeline.mjs
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
/** @typedef {import('./types.js').Config} Config */
|
|
2
|
+
/** @typedef {import('./types.js').Operations} Operations */
|
|
3
|
+
/** @typedef {import('./types.js').Summary} Summary */
|
|
4
|
+
|
|
5
|
+
import { appendFile, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
6
|
import { tmpdir } from 'node:os';
|
|
3
7
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
8
|
import packageJson from '../package.json' with { type: 'json' };
|
|
@@ -11,9 +15,10 @@ import { canonicalizeConfig, hashConfig, hashText } from './manifest.mjs';
|
|
|
11
15
|
import { computeRunMetrics } from './metrics.mjs';
|
|
12
16
|
import { publish } from './publish.mjs';
|
|
13
17
|
import { renderPrompt } from './prompts.mjs';
|
|
14
|
-
import { banner, describeAgent, ensureConfirmationAvailable, log, openTerminalInput, report } from './reporter.mjs';
|
|
18
|
+
import { banner, describeAgent, ensureConfirmationAvailable, log, openTerminalInput, report, resetRenderer, setRenderer, writeOutput } from './reporter.mjs';
|
|
15
19
|
import { RunState, slugFor } from './state.mjs';
|
|
16
20
|
import { runPhases } from './runner.mjs';
|
|
21
|
+
import { createTuiRenderer, tuiEnabled } from './tui.mjs';
|
|
17
22
|
import { formatFindings } from './verdict.mjs';
|
|
18
23
|
import { planWorktree, resumedWorktree, setupWorktree } from './worktree.mjs';
|
|
19
24
|
|
|
@@ -61,6 +66,18 @@ function configOverrideRecord(config, path) {
|
|
|
61
66
|
};
|
|
62
67
|
}
|
|
63
68
|
|
|
69
|
+
async function resolveOperatorNote(args, cwd) {
|
|
70
|
+
const text = args.note ?? (args.noteFile ? await readFile(resolve(cwd, args.noteFile), 'utf8') : null);
|
|
71
|
+
if (text !== null && !text.trim()) {
|
|
72
|
+
throw new Error('--note and --note-file must contain non-whitespace text.');
|
|
73
|
+
}
|
|
74
|
+
return text === null ? null : { text };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function operatorNoteBlock(text) {
|
|
78
|
+
return `## Operator note (read this first)\n\nAn operator is resuming this phase and has provided the following instruction. Treat it as authoritative for what remains to be done. Do not redo or revert work that is already complete; do only what is needed to satisfy it and the phase's postconditions.\n\n${text}\n\n---\n\n`;
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
async function fileExists(path) {
|
|
65
82
|
try {
|
|
66
83
|
return (await stat(path)).isFile();
|
|
@@ -133,6 +150,7 @@ function engineRecord(agent) {
|
|
|
133
150
|
};
|
|
134
151
|
}
|
|
135
152
|
|
|
153
|
+
/** @returns {import('./types.js').ManifestEntry} */
|
|
136
154
|
function manifestEntry(phase, ctx, values = {}) {
|
|
137
155
|
return {
|
|
138
156
|
phase: phase.name,
|
|
@@ -273,7 +291,7 @@ async function runGate(phase, ctx) {
|
|
|
273
291
|
timeoutMs: ctx.config.timeoutMs,
|
|
274
292
|
activeProcessPath: ctx.activeProcessPath,
|
|
275
293
|
onOutput: (text) => {
|
|
276
|
-
|
|
294
|
+
writeOutput(text);
|
|
277
295
|
void tee(logFile, text);
|
|
278
296
|
},
|
|
279
297
|
});
|
|
@@ -344,7 +362,7 @@ async function runPublish(phase, ctx, { remote, branch, base, approvedSha }) {
|
|
|
344
362
|
return { ok: Boolean(result), result, manifest, failure };
|
|
345
363
|
}
|
|
346
364
|
|
|
347
|
-
async function withRetries(phase, operation, { failed = () => false } = {}) {
|
|
365
|
+
async function withRetries(phase, operation, { failed = /** @type {(value: any) => boolean} */ (() => false) } = {}) {
|
|
348
366
|
const maxAttempts = phase.retry?.maxAttempts ?? 1;
|
|
349
367
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
350
368
|
try {
|
|
@@ -369,7 +387,7 @@ async function runSetup(commands, ctx) {
|
|
|
369
387
|
timeoutMs: ctx.config.timeoutMs,
|
|
370
388
|
activeProcessPath: ctx.activeProcessPath,
|
|
371
389
|
onOutput: (text) => {
|
|
372
|
-
|
|
390
|
+
writeOutput(text);
|
|
373
391
|
void tee(logFile, text);
|
|
374
392
|
},
|
|
375
393
|
});
|
|
@@ -450,12 +468,16 @@ async function runAgent(phase, ctx, variables) {
|
|
|
450
468
|
const agentRepo = sourceSnapshot?.path ?? ctx.worktree;
|
|
451
469
|
|
|
452
470
|
try {
|
|
453
|
-
const
|
|
471
|
+
const rendered = await renderPrompt(phase.prompt, {
|
|
454
472
|
...variables,
|
|
455
473
|
...(sourceSnapshot ? { REPO: agentRepo } : {}),
|
|
456
474
|
}, {
|
|
457
475
|
projectPromptDir: ctx.promptDir,
|
|
458
476
|
});
|
|
477
|
+
const receivesOperatorNote = ctx.note
|
|
478
|
+
&& (ctx.note.targetPhase === phase.name || ctx.note.repairPhase === phase.name);
|
|
479
|
+
const prompt = receivesOperatorNote ? `${operatorNoteBlock(ctx.note.text)}${rendered.prompt}` : rendered.prompt;
|
|
480
|
+
const { path } = rendered;
|
|
459
481
|
const promptHash = hashText(prompt);
|
|
460
482
|
log(` engine: ${agent.name}${agent.model ? ` model: ${agent.model}` : ''}${agent.effort ? ` effort: ${agent.effort}` : ''} prompt: ${path}`);
|
|
461
483
|
if (engineOverride) log(` resumed override: ${describeAgent(engineOverride.from)} → ${describeAgent(engineOverride.to)}`);
|
|
@@ -507,7 +529,7 @@ async function runAgent(phase, ctx, variables) {
|
|
|
507
529
|
const emit = (text) => {
|
|
508
530
|
if (!text) return;
|
|
509
531
|
bytesEmitted += Buffer.byteLength(text);
|
|
510
|
-
|
|
532
|
+
writeOutput(text);
|
|
511
533
|
pendingLogWrites.push(tee(logFile, text));
|
|
512
534
|
};
|
|
513
535
|
let commandError;
|
|
@@ -573,10 +595,12 @@ export async function runLoop(options = {}) {
|
|
|
573
595
|
const cwd = args.cwd ?? process.cwd();
|
|
574
596
|
const rootGit = new GitFacade(cwd);
|
|
575
597
|
const repoRoot = await rootGit.toplevel();
|
|
598
|
+
const operatorNote = await resolveOperatorNote(args, cwd);
|
|
576
599
|
|
|
577
600
|
if (args.config && args.overrideEngine) {
|
|
578
601
|
throw new Error('--config and --override-engine cannot be used together; configure engines in the supplied config file.');
|
|
579
602
|
}
|
|
603
|
+
/** @type {Config} */
|
|
580
604
|
const config = await loadConfig(repoRoot, {
|
|
581
605
|
...(args.maxRounds ? { maxRounds: args.maxRounds } : {}),
|
|
582
606
|
...(args.phases ? { phases: args.phases } : {}),
|
|
@@ -616,6 +640,7 @@ export async function runLoop(options = {}) {
|
|
|
616
640
|
);
|
|
617
641
|
|
|
618
642
|
let runStarted = false;
|
|
643
|
+
let tuiRenderer = null;
|
|
619
644
|
try {
|
|
620
645
|
const persistedName = args.resume ? state.data.name : null;
|
|
621
646
|
if (persistedName && args.name && slugFor(args.name) !== persistedName) {
|
|
@@ -787,6 +812,7 @@ export async function runLoop(options = {}) {
|
|
|
787
812
|
engineOverrides,
|
|
788
813
|
budgetWarnings: new Set(),
|
|
789
814
|
configHash: hashConfig(config),
|
|
815
|
+
note: operatorNote,
|
|
790
816
|
resume: Boolean(args.resume),
|
|
791
817
|
dryRun: args.dryRun,
|
|
792
818
|
remote,
|
|
@@ -835,6 +861,7 @@ export async function runLoop(options = {}) {
|
|
|
835
861
|
log(`run dir : ${state.dir}`);
|
|
836
862
|
log(`phases : ${config.resolvedPhases.map((phase) => phase.name).join(' → ')}`);
|
|
837
863
|
|
|
864
|
+
/** @type {Summary} */
|
|
838
865
|
const summary = {
|
|
839
866
|
phases: [],
|
|
840
867
|
stalled: null,
|
|
@@ -849,6 +876,24 @@ export async function runLoop(options = {}) {
|
|
|
849
876
|
pullRequest: state.data.pullRequest ?? state.manifestMetadata.pullRequest ?? null,
|
|
850
877
|
};
|
|
851
878
|
|
|
879
|
+
if (!args.dryRun && tuiEnabled({ isTTY: process.stdout.isTTY, noTui: args.noTui })) {
|
|
880
|
+
tuiRenderer = createTuiRenderer({ config, state, summary });
|
|
881
|
+
setRenderer(tuiRenderer);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const operations = /** @satisfies {Operations} */ ({
|
|
885
|
+
budgetStatus,
|
|
886
|
+
markBudgetExhaustedComplete,
|
|
887
|
+
manifestEntry,
|
|
888
|
+
markStalledManifest,
|
|
889
|
+
recordBudgetStall,
|
|
890
|
+
recordManifest,
|
|
891
|
+
runAgent,
|
|
892
|
+
runGate,
|
|
893
|
+
runPublish,
|
|
894
|
+
withRetries,
|
|
895
|
+
});
|
|
896
|
+
|
|
852
897
|
await runPhases({
|
|
853
898
|
args,
|
|
854
899
|
config,
|
|
@@ -861,24 +906,18 @@ export async function runLoop(options = {}) {
|
|
|
861
906
|
baseBranch,
|
|
862
907
|
confirmInput,
|
|
863
908
|
terminalOpener,
|
|
864
|
-
operations
|
|
865
|
-
budgetStatus,
|
|
866
|
-
markBudgetExhaustedComplete,
|
|
867
|
-
manifestEntry,
|
|
868
|
-
markStalledManifest,
|
|
869
|
-
recordBudgetStall,
|
|
870
|
-
recordManifest,
|
|
871
|
-
runAgent,
|
|
872
|
-
runGate,
|
|
873
|
-
runPublish,
|
|
874
|
-
withRetries,
|
|
875
|
-
},
|
|
909
|
+
operations,
|
|
876
910
|
});
|
|
877
911
|
|
|
878
912
|
await state.record({
|
|
879
913
|
status: summary.stalled ? 'stalled' : 'completed',
|
|
880
914
|
...(summary.stalled ? { stalled: summary.stalled } : {}),
|
|
881
915
|
});
|
|
916
|
+
if (tuiRenderer) {
|
|
917
|
+
tuiRenderer.stop();
|
|
918
|
+
resetRenderer();
|
|
919
|
+
tuiRenderer = null;
|
|
920
|
+
}
|
|
882
921
|
report(summary, formatFindings);
|
|
883
922
|
return summary;
|
|
884
923
|
|
|
@@ -886,6 +925,10 @@ export async function runLoop(options = {}) {
|
|
|
886
925
|
if (runStarted && !args.dryRun) await state.record({ status: 'stalled' }).catch(() => {});
|
|
887
926
|
throw error;
|
|
888
927
|
} finally {
|
|
928
|
+
if (tuiRenderer) {
|
|
929
|
+
tuiRenderer.stop();
|
|
930
|
+
resetRenderer();
|
|
931
|
+
}
|
|
889
932
|
await state.release();
|
|
890
933
|
}
|
|
891
934
|
}
|
package/src/publish.mjs
CHANGED
|
@@ -25,7 +25,8 @@ export function parsePullRequestDescription(contents) {
|
|
|
25
25
|
return { title, body };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export function githubBackend(
|
|
28
|
+
export function githubBackend(options = {}) {
|
|
29
|
+
const { cwd, git = new GitFacade(cwd), runner = runCommand, env } = /** @type {any} */ (options);
|
|
29
30
|
const repositories = new Map();
|
|
30
31
|
|
|
31
32
|
async function resolveRepository(remote) {
|
|
@@ -128,17 +129,8 @@ function assertVerifiedPullRequest(pullRequest, { base, branch, localSha, draft
|
|
|
128
129
|
* backend is GitHub's `gh` CLI; other remotes can supply the same precheck,
|
|
129
130
|
* view, create, and update operations without changing this use case.
|
|
130
131
|
*/
|
|
131
|
-
export async function publish({
|
|
132
|
-
git,
|
|
133
|
-
remote,
|
|
134
|
-
branch,
|
|
135
|
-
base,
|
|
136
|
-
prBodyPath,
|
|
137
|
-
draft = true,
|
|
138
|
-
backend = 'github',
|
|
139
|
-
approvedSha = null,
|
|
140
|
-
env,
|
|
141
|
-
}) {
|
|
132
|
+
export async function publish(options = {}) {
|
|
133
|
+
const { git, remote, branch, base, prBodyPath, draft = true, backend = 'github', approvedSha = null, env } = /** @type {any} */ (options);
|
|
142
134
|
if (!git || typeof git.push !== 'function' || typeof git.lsRemote !== 'function') {
|
|
143
135
|
throw new PublishError('Publish requires a GitFacade with push and lsRemote operations.');
|
|
144
136
|
}
|