@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.
@@ -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
+ }
@@ -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
+ }