@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/prompts.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const packagePrompts = join(dirname(fileURLToPath(import.meta.url)), '..', 'prompts');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Load a prompt template, preferring the project's copy over the packaged one.
|
|
9
|
+
*
|
|
10
|
+
* Overriding is per-file rather than all-or-nothing: a project that only needs
|
|
11
|
+
* a different reviewer drops in `review.md` and keeps receiving improvements to
|
|
12
|
+
* every other phase.
|
|
13
|
+
*/
|
|
14
|
+
export async function loadTemplate(name, { projectPromptDir }) {
|
|
15
|
+
const candidates = [join(projectPromptDir, `${name}.md`), join(packagePrompts, `${name}.md`)];
|
|
16
|
+
for (const candidate of candidates) {
|
|
17
|
+
try {
|
|
18
|
+
return { path: candidate, body: await readFile(candidate, 'utf8') };
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (error.code !== 'ENOENT') throw error;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`No prompt template "${name}.md" in ${projectPromptDir} or ${packagePrompts}.`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Substitute `{{VARIABLE}}` placeholders, failing on any that go unresolved.
|
|
28
|
+
*
|
|
29
|
+
* An unresolved placeholder reaching an agent is worse than a crash: the agent
|
|
30
|
+
* reads it as literal text and improvises around it, producing work that looks
|
|
31
|
+
* plausible and targets the wrong thing.
|
|
32
|
+
*/
|
|
33
|
+
export function interpolate(body, variables) {
|
|
34
|
+
const missing = new Set();
|
|
35
|
+
const filled = body.replace(/\{\{\s*([A-Z0-9_]+)\s*\}\}/g, (match, key) => {
|
|
36
|
+
const value = variables[key];
|
|
37
|
+
if (value === undefined || value === null) {
|
|
38
|
+
missing.add(key);
|
|
39
|
+
return match;
|
|
40
|
+
}
|
|
41
|
+
return String(value);
|
|
42
|
+
});
|
|
43
|
+
if (missing.size) {
|
|
44
|
+
throw new Error(`Prompt has unresolved variables: ${[...missing].sort().join(', ')}`);
|
|
45
|
+
}
|
|
46
|
+
return filled;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function renderPrompt(name, variables, options) {
|
|
50
|
+
const { path, body } = await loadTemplate(name, options);
|
|
51
|
+
return { path, prompt: interpolate(body, variables) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export { packagePrompts };
|
package/src/state.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Run state, persisted after every phase.
|
|
6
|
+
*
|
|
7
|
+
* The point is resumability: a run that dies in the docs phase should restart
|
|
8
|
+
* at the docs phase, not re-implement the work item. State lives next to the
|
|
9
|
+
* run's logs rather than in a session, so `--resume` works even if the run is
|
|
10
|
+
* resumed with a different engine than started it.
|
|
11
|
+
*/
|
|
12
|
+
export function slugFor(input) {
|
|
13
|
+
if (!input) return '';
|
|
14
|
+
return input
|
|
15
|
+
.split('/')
|
|
16
|
+
.at(-1)
|
|
17
|
+
.replace(/\.(md|txt|markdown)$/i, '')
|
|
18
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
19
|
+
.replace(/^-|-$/g, '')
|
|
20
|
+
.toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class RunState {
|
|
24
|
+
constructor(dir, data, readOnly = false) {
|
|
25
|
+
this.dir = dir;
|
|
26
|
+
this.data = data;
|
|
27
|
+
this.readOnly = readOnly;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static async open(runsDir, slug, seed, { readOnly = false } = {}) {
|
|
31
|
+
const dir = join(runsDir, slug);
|
|
32
|
+
await mkdir(dir, { recursive: true });
|
|
33
|
+
const path = join(dir, 'state.json');
|
|
34
|
+
let data;
|
|
35
|
+
try {
|
|
36
|
+
data = JSON.parse(await readFile(path, 'utf8'));
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.code !== 'ENOENT') throw error;
|
|
39
|
+
data = {
|
|
40
|
+
startedAt: new Date().toISOString(),
|
|
41
|
+
completed: [],
|
|
42
|
+
rounds: {},
|
|
43
|
+
reviewedShas: {},
|
|
44
|
+
...seed,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return new RunState(dir, data, readOnly);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get path() {
|
|
51
|
+
return join(this.dir, 'state.json');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
logPath(name) {
|
|
55
|
+
return join(this.dir, `${name}.log`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
verdictPath(round) {
|
|
59
|
+
return join(this.dir, `verdict-round-${round}.json`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
isComplete(name) {
|
|
63
|
+
return this.data.completed.includes(name);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async markComplete(name, details = {}) {
|
|
67
|
+
if (!this.data.completed.includes(name)) this.data.completed.push(name);
|
|
68
|
+
this.data.phases = { ...this.data.phases, [name]: { finishedAt: new Date().toISOString(), ...details } };
|
|
69
|
+
await this.save();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async record(patch) {
|
|
73
|
+
Object.assign(this.data, patch);
|
|
74
|
+
await this.save();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Persist, unless this is a dry run.
|
|
79
|
+
*
|
|
80
|
+
* A dry run that recorded phases as complete would poison the next
|
|
81
|
+
* `--resume`, which would then skip work that never actually happened.
|
|
82
|
+
*/
|
|
83
|
+
async save() {
|
|
84
|
+
if (this.readOnly) return;
|
|
85
|
+
this.data.updatedAt = new Date().toISOString();
|
|
86
|
+
await writeFile(this.path, `${JSON.stringify(this.data, null, 2)}\n`);
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/verdict.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
export const APPROVED = 'APPROVED';
|
|
4
|
+
export const CHANGES_REQUESTED = 'CHANGES_REQUESTED';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pull the JSON object out of whatever the reviewer wrote.
|
|
8
|
+
*
|
|
9
|
+
* Agents fence JSON in markdown often enough that rejecting it would be
|
|
10
|
+
* pedantic, so tolerate the fence — but tolerate nothing about the contents.
|
|
11
|
+
*/
|
|
12
|
+
function extractJson(text) {
|
|
13
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
14
|
+
const candidate = (fenced ? fenced[1] : text).trim();
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(candidate);
|
|
17
|
+
} catch (error) {
|
|
18
|
+
throw new Error(`Verdict file is not valid JSON: ${error.message}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validate a verdict.
|
|
24
|
+
*
|
|
25
|
+
* This fails closed on purpose. A missing, malformed, or unrecognized verdict
|
|
26
|
+
* stalls the run rather than defaulting to either outcome: defaulting to
|
|
27
|
+
* approval ships unreviewed code, and defaulting to changes burns rounds
|
|
28
|
+
* against a reviewer that is not actually reporting.
|
|
29
|
+
*/
|
|
30
|
+
export function parseVerdict(text) {
|
|
31
|
+
const data = extractJson(text);
|
|
32
|
+
const verdict = String(data.verdict ?? '').toUpperCase();
|
|
33
|
+
if (![APPROVED, CHANGES_REQUESTED].includes(verdict)) {
|
|
34
|
+
throw new Error(`Verdict must be ${APPROVED} or ${CHANGES_REQUESTED}, got ${JSON.stringify(data.verdict)}.`);
|
|
35
|
+
}
|
|
36
|
+
const blocking = Array.isArray(data.blocking) ? data.blocking : [];
|
|
37
|
+
if (verdict === CHANGES_REQUESTED && blocking.length === 0) {
|
|
38
|
+
throw new Error(`Verdict is ${CHANGES_REQUESTED} but lists no blocking findings.`);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
verdict,
|
|
42
|
+
blocking,
|
|
43
|
+
nits: Array.isArray(data.nits) ? data.nits : [],
|
|
44
|
+
summary: typeof data.summary === 'string' ? data.summary : '',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readVerdict(path) {
|
|
49
|
+
let text;
|
|
50
|
+
try {
|
|
51
|
+
text = await readFile(path, 'utf8');
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error.code === 'ENOENT') {
|
|
54
|
+
throw new Error(`The review phase did not write a verdict to ${path}.`);
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
return parseVerdict(text);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatFindings(findings) {
|
|
62
|
+
if (!findings.length) return '(none)';
|
|
63
|
+
return findings
|
|
64
|
+
.map((finding, index) => {
|
|
65
|
+
const where = [finding.file, finding.line].filter(Boolean).join(':');
|
|
66
|
+
return `${index + 1}. ${where ? `${where} — ` : ''}${finding.issue ?? finding.summary ?? ''}`;
|
|
67
|
+
})
|
|
68
|
+
.join('\n');
|
|
69
|
+
}
|