@syntax-syllogism/aloop 0.5.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/CHANGELOG.md +141 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/bin/loop.mjs +90 -0
- package/package.json +46 -0
- package/presets/work-item/README.md +31 -0
- package/presets/work-item/loop.config.mjs +13 -0
- package/presets/work-item/prompts/address.md +57 -0
- package/presets/work-item/prompts/docs.md +31 -0
- package/presets/work-item/prompts/git.md +44 -0
- package/presets/work-item/prompts/implement.md +50 -0
- package/presets/work-item/prompts/review.md +77 -0
- package/prompts/address.md +59 -0
- package/prompts/docs.md +23 -0
- package/prompts/git.md +29 -0
- package/prompts/implement.md +45 -0
- package/prompts/review.md +74 -0
- package/src/adapters.mjs +281 -0
- package/src/command.mjs +65 -0
- package/src/config.mjs +175 -0
- package/src/entrypoint.mjs +22 -0
- package/src/git.mjs +91 -0
- package/src/index.mjs +6 -0
- package/src/pipeline.mjs +813 -0
- package/src/prompts.mjs +54 -0
- package/src/state.mjs +88 -0
- package/src/verdict.mjs +69 -0
package/src/config.mjs
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { validateAgent } from './adapters.mjs';
|
|
6
|
+
|
|
7
|
+
const defaults = {
|
|
8
|
+
baseBranch: 'master',
|
|
9
|
+
branchPrefix: 'feat/',
|
|
10
|
+
// Null means "detect", which prefers `origin`. Set this explicitly in any
|
|
11
|
+
// repo with several remotes — pushing a feature branch to the wrong one is
|
|
12
|
+
// not something the run can undo for you.
|
|
13
|
+
remote: null,
|
|
14
|
+
adapters: {},
|
|
15
|
+
engines: { default: { name: 'claude' } },
|
|
16
|
+
phases: ['implement', 'gate', 'review', 'address', 'docs', 'git'],
|
|
17
|
+
gate: [],
|
|
18
|
+
// Commands run once in a newly created worktree before the first phase.
|
|
19
|
+
// An empty list keeps the generic runner package-manager agnostic.
|
|
20
|
+
setup: [],
|
|
21
|
+
maxRounds: 3,
|
|
22
|
+
// Per-command budget. An implement phase on a real work item routinely
|
|
23
|
+
// runs past half an hour; a cap that tight kills healthy runs mid-edit.
|
|
24
|
+
timeoutMs: 60 * 60 * 1000,
|
|
25
|
+
worktrees: true,
|
|
26
|
+
worktreeRoot: null,
|
|
27
|
+
promptDir: '.loop/prompts',
|
|
28
|
+
runsDir: '.loop/runs',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Built-in phase definitions.
|
|
33
|
+
*
|
|
34
|
+
* `verdict: true` marks a phase whose agent must emit a machine-readable
|
|
35
|
+
* verdict; `role: 'repair'` marks a phase that exists to answer one. The
|
|
36
|
+
* runner pairs them during normalization, which is what turns a flat list into
|
|
37
|
+
* a loop without the config having to describe control flow.
|
|
38
|
+
*/
|
|
39
|
+
const builtinPhases = {
|
|
40
|
+
implement: { kind: 'agent', prompt: 'implement' },
|
|
41
|
+
gate: { kind: 'gate' },
|
|
42
|
+
review: { kind: 'agent', prompt: 'review', verdict: true },
|
|
43
|
+
address: { kind: 'agent', prompt: 'address', role: 'repair' },
|
|
44
|
+
docs: { kind: 'agent', prompt: 'docs' },
|
|
45
|
+
// `requiresCleanTree` makes the runner assert a clean worktree before this
|
|
46
|
+
// phase runs. The git phase pushes and opens a PR; it must never start over
|
|
47
|
+
// uncommitted work an earlier phase left behind, and its prompt forbids it
|
|
48
|
+
// from committing, so a dirty tree there is a pipeline error, not something
|
|
49
|
+
// for the agent to reconcile.
|
|
50
|
+
git: { kind: 'agent', prompt: 'git', requiresCleanTree: true },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
async function exists(path) {
|
|
54
|
+
try {
|
|
55
|
+
await access(path, constants.F_OK);
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function toPhase(entry, index) {
|
|
63
|
+
if (typeof entry === 'string') {
|
|
64
|
+
const builtin = builtinPhases[entry];
|
|
65
|
+
if (!builtin) throw new Error(`Unknown phase "${entry}"; define it as an object to add a custom phase.`);
|
|
66
|
+
return { name: entry, ...builtin };
|
|
67
|
+
}
|
|
68
|
+
if (!entry?.name) throw new Error(`Phase at position ${index} is missing a name.`);
|
|
69
|
+
const builtin = builtinPhases[entry.name] ?? {};
|
|
70
|
+
const phase = { ...builtin, ...entry };
|
|
71
|
+
if (!['agent', 'gate'].includes(phase.kind)) {
|
|
72
|
+
throw new Error(`Phase "${phase.name}" needs kind "agent" or "gate".`);
|
|
73
|
+
}
|
|
74
|
+
if (phase.kind === 'agent' && !phase.prompt) phase.prompt = phase.name;
|
|
75
|
+
return phase;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Collapse a flat phase list into the shape the runner executes.
|
|
80
|
+
*
|
|
81
|
+
* A verdict phase absorbs the repair phases that follow it, plus any gate that
|
|
82
|
+
* immediately preceded it, so `[gate, review, address]` becomes "gate, then
|
|
83
|
+
* review, and while the verdict asks for changes: address, gate, review again".
|
|
84
|
+
* Declaring the loop this way keeps the config readable while leaving the
|
|
85
|
+
* iteration cap in the runner where it belongs.
|
|
86
|
+
*/
|
|
87
|
+
export function normalizePhases(entries, { maxRounds }) {
|
|
88
|
+
const list = entries.map(toPhase);
|
|
89
|
+
const result = [];
|
|
90
|
+
for (let index = 0; index < list.length; index += 1) {
|
|
91
|
+
const phase = list[index];
|
|
92
|
+
if (phase.role === 'repair') {
|
|
93
|
+
throw new Error(`Phase "${phase.name}" is a repair phase and must follow a phase that emits a verdict.`);
|
|
94
|
+
}
|
|
95
|
+
if (!phase.verdict) {
|
|
96
|
+
result.push(phase);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const repair = [];
|
|
100
|
+
let next = index + 1;
|
|
101
|
+
while (next < list.length && list[next].role === 'repair') {
|
|
102
|
+
repair.push(list[next]);
|
|
103
|
+
next += 1;
|
|
104
|
+
}
|
|
105
|
+
const precedingGate = result.at(-1)?.kind === 'gate' ? result.at(-1) : null;
|
|
106
|
+
result.push({
|
|
107
|
+
...phase,
|
|
108
|
+
maxRounds: phase.maxRounds ?? maxRounds,
|
|
109
|
+
repair: repair.length
|
|
110
|
+
? [...repair, ...(precedingGate ? [{ ...precedingGate, recheck: true }] : [])]
|
|
111
|
+
: [],
|
|
112
|
+
});
|
|
113
|
+
index = next - 1;
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeAgent(entry, phaseName, customAdapters) {
|
|
119
|
+
const agent = typeof entry === 'string' ? { name: entry } : entry;
|
|
120
|
+
if (!agent || typeof agent !== 'object' || Array.isArray(agent)) {
|
|
121
|
+
throw new Error(`Engine "${phaseName}" must be a name string or descriptor object.`);
|
|
122
|
+
}
|
|
123
|
+
if (typeof agent.name !== 'string' || !agent.name) {
|
|
124
|
+
throw new Error(`Engine "${phaseName}" is missing a non-empty name.`);
|
|
125
|
+
}
|
|
126
|
+
if (agent.model !== undefined && (typeof agent.model !== 'string' || !agent.model)) {
|
|
127
|
+
throw new Error(`Engine "${phaseName}" has an invalid model; expected a non-empty string.`);
|
|
128
|
+
}
|
|
129
|
+
if (agent.effort !== undefined && (typeof agent.effort !== 'string' || !agent.effort)) {
|
|
130
|
+
throw new Error(`Engine "${phaseName}" has an invalid effort; expected a non-empty string.`);
|
|
131
|
+
}
|
|
132
|
+
return validateAgent(
|
|
133
|
+
{ name: agent.name, ...(agent.model ? { model: agent.model } : {}), ...(agent.effort ? { effort: agent.effort } : {}) },
|
|
134
|
+
customAdapters,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function normalizeEngines(engines, customAdapters) {
|
|
139
|
+
if (!engines || typeof engines !== 'object' || Array.isArray(engines)) {
|
|
140
|
+
throw new Error('`engines` must be an object with a `default` entry.');
|
|
141
|
+
}
|
|
142
|
+
const normalized = Object.fromEntries(
|
|
143
|
+
Object.entries(engines).map(([phaseName, entry]) => [phaseName, normalizeAgent(entry, phaseName, customAdapters)]),
|
|
144
|
+
);
|
|
145
|
+
if (!normalized.default) throw new Error('`engines` must define a `default` entry.');
|
|
146
|
+
return normalized;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function loadConfig(cwd, overrides = {}, configPath) {
|
|
150
|
+
const path = configPath ? resolve(configPath) : join(cwd, 'loop.config.mjs');
|
|
151
|
+
if (configPath && !(await exists(path))) {
|
|
152
|
+
throw new Error(`Loop config file does not exist: ${path}`);
|
|
153
|
+
}
|
|
154
|
+
const configured = (await exists(path))
|
|
155
|
+
? ((await import(`${pathToFileURL(path).href}?t=${Date.now()}`)).default ?? {})
|
|
156
|
+
: {};
|
|
157
|
+
const merged = {
|
|
158
|
+
...defaults,
|
|
159
|
+
...configured,
|
|
160
|
+
...overrides,
|
|
161
|
+
adapters: { ...defaults.adapters, ...configured.adapters, ...overrides.adapters },
|
|
162
|
+
engines: { ...defaults.engines, ...configured.engines, ...overrides.engines },
|
|
163
|
+
};
|
|
164
|
+
merged.engines = normalizeEngines(merged.engines, merged.adapters);
|
|
165
|
+
if (!Array.isArray(merged.setup) || merged.setup.some((command) => typeof command !== 'string')) {
|
|
166
|
+
throw new Error('`setup` must be an array of command strings.');
|
|
167
|
+
}
|
|
168
|
+
merged.resolvedPhases = normalizePhases(merged.phases, { maxRounds: merged.maxRounds });
|
|
169
|
+
if (merged.resolvedPhases.some((phase) => phase.kind === 'gate' && !(phase.commands ?? merged.gate).length)) {
|
|
170
|
+
throw new Error('The pipeline has a gate phase but no gate commands; set `gate: [...]` in loop.config.mjs.');
|
|
171
|
+
}
|
|
172
|
+
return merged;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export { defaults, builtinPhases };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether the module identified by `moduleUrl` is the script Node was asked to
|
|
7
|
+
* run.
|
|
8
|
+
*
|
|
9
|
+
* `process.argv[1]` is the path as typed, which for a globally installed
|
|
10
|
+
* package is npm's bin symlink (`.../bin/aloop`), while `import.meta.url`
|
|
11
|
+
* is always the fully resolved module path. Comparing the two without resolving
|
|
12
|
+
* symlinks makes every global install a silent no-op, so resolve both sides.
|
|
13
|
+
*/
|
|
14
|
+
export function isMainEntrypoint(moduleUrl) {
|
|
15
|
+
const entry = process.argv[1];
|
|
16
|
+
if (!entry) return false;
|
|
17
|
+
try {
|
|
18
|
+
return pathToFileURL(realpathSync(resolve(entry))).href === moduleUrl;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/git.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { runCommand } from './command.mjs';
|
|
2
|
+
|
|
3
|
+
export class GitFacade {
|
|
4
|
+
constructor(cwd, runner = runCommand) {
|
|
5
|
+
this.cwd = cwd;
|
|
6
|
+
this.runner = runner;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async run(args, options = {}) {
|
|
10
|
+
return this.runner('git', args, { cwd: this.cwd, ...options });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async output(args, options = {}) {
|
|
14
|
+
return (await this.run(args, options)).stdout.trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async toplevel() {
|
|
18
|
+
return this.output(['rev-parse', '--show-toplevel']);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async currentBranch() {
|
|
22
|
+
return this.output(['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async revParse(ref = 'HEAD') {
|
|
26
|
+
return this.output(['rev-parse', ref]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async status() {
|
|
30
|
+
return this.output(['status', '--porcelain']);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async isDirty() {
|
|
34
|
+
return Boolean(await this.status());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async localBranchExists(branch) {
|
|
38
|
+
try {
|
|
39
|
+
await this.run(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
|
|
40
|
+
return true;
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async remotes() {
|
|
47
|
+
return (await this.output(['remote'])).split('\n').filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async remoteExists(remote) {
|
|
51
|
+
return (await this.remotes()).includes(remote);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async defaultRemote() {
|
|
55
|
+
const remotes = await this.remotes();
|
|
56
|
+
if (remotes.includes('origin')) return 'origin';
|
|
57
|
+
return remotes[0] ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Create (or reuse) a worktree checked out to `branch`.
|
|
62
|
+
*
|
|
63
|
+
* Reuse matters for `--resume`: a run that died after phase two must land in
|
|
64
|
+
* the same tree it was building, not a fresh one branched off the base again.
|
|
65
|
+
*/
|
|
66
|
+
async addWorktree(path, branch, base) {
|
|
67
|
+
if (await this.localBranchExists(branch)) {
|
|
68
|
+
await this.run(['worktree', 'add', path, branch]);
|
|
69
|
+
return { path, branch, created: false };
|
|
70
|
+
}
|
|
71
|
+
await this.run(['worktree', 'add', '-b', branch, path, base]);
|
|
72
|
+
return { path, branch, created: true };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async worktreePath(branch) {
|
|
76
|
+
const output = await this.output(['worktree', 'list', '--porcelain']);
|
|
77
|
+
const blocks = output.split('\n\n');
|
|
78
|
+
for (const block of blocks) {
|
|
79
|
+
const lines = block.split('\n');
|
|
80
|
+
const worktree = lines.find((line) => line.startsWith('worktree '))?.slice('worktree '.length);
|
|
81
|
+
const ref = lines.find((line) => line.startsWith('branch '))?.slice('branch '.length);
|
|
82
|
+
if (worktree && ref === `refs/heads/${branch}`) return worktree;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async removeWorktree(path) {
|
|
88
|
+
await this.run(['worktree', 'remove', '--force', path]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { runLoop } from './pipeline.mjs';
|
|
2
|
+
export { loadConfig, normalizePhases, builtinPhases, defaults } from './config.mjs';
|
|
3
|
+
export { adapterFor, engineForPhase, adapters } from './adapters.mjs';
|
|
4
|
+
export { renderPrompt, loadTemplate, interpolate } from './prompts.mjs';
|
|
5
|
+
export { parseVerdict, readVerdict, formatFindings, APPROVED, CHANGES_REQUESTED } from './verdict.mjs';
|
|
6
|
+
export { RunState, slugFor } from './state.mjs';
|