@syntax-syllogism/aloop 0.8.3 → 0.8.4
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 +9 -0
- package/README.md +6 -6
- package/bin/loop.mjs +6 -0
- package/package.json +1 -1
- package/src/pipeline.mjs +41 -9
- package/src/runner.mjs +73 -13
- package/src/state.mjs +9 -0
- package/src/types.d.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0/).
|
|
7
7
|
|
|
8
|
+
## [0.8.4] - 2026-09-20
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Nested repair baselines are now properly cleared when rerunning reviews
|
|
13
|
+
- Resume commit checks are now correctly skipped during dry runs
|
|
14
|
+
- Resume of existing commits now works correctly in read-only working directories
|
|
15
|
+
- Model and engine flags are now handled correctly
|
|
16
|
+
|
|
8
17
|
## [0.8.0] - 2026-09-20
|
|
9
18
|
|
|
10
19
|
### Added
|
package/README.md
CHANGED
|
@@ -113,12 +113,12 @@ export default { publish: { backend: gitlabBackend({ transport: myMcpTransport }
|
|
|
113
113
|
### Bring your own engine
|
|
114
114
|
|
|
115
115
|
Register an adapter under `adapters` when another CLI should run a phase. Its
|
|
116
|
-
`command({ prompt, cwd, addDirs,
|
|
117
|
-
`
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
accordingly; the adapter must translate `permissions` into its own
|
|
121
|
-
or write-capable invocation flags. It may also provide `efforts`
|
|
116
|
+
`command({ prompt, cwd, addDirs, permissions, artifactOnly, agent })` function
|
|
117
|
+
returns `{ command, args }`. For a read-only phase, `cwd` is a disposable
|
|
118
|
+
read-only source snapshot of the saved worktree and `addDirs` contains only
|
|
119
|
+
writable artifact roots. The runner supplies a permission level and restricts
|
|
120
|
+
`addDirs` accordingly; the adapter must translate `permissions` into its own
|
|
121
|
+
read-only or write-capable invocation flags. It may also provide `efforts`
|
|
122
122
|
validation and `createRenderer()` for streaming output. Custom adapter flags
|
|
123
123
|
are vendor-specific and are not verified by aloop. See the [phase permission
|
|
124
124
|
contract](docs/loop.md#phase-permissions) before enabling unattended runs.
|
package/bin/loop.mjs
CHANGED
|
@@ -32,6 +32,8 @@ export function parseLoopArgs(argv) {
|
|
|
32
32
|
branch: { type: 'string', short: 'b' },
|
|
33
33
|
'base-branch': { type: 'string' },
|
|
34
34
|
engine: { type: 'string', short: 'e' },
|
|
35
|
+
model: { type: 'string', short: 'm' },
|
|
36
|
+
effort: { type: 'string' },
|
|
35
37
|
'override-engine': { type: 'boolean' },
|
|
36
38
|
config: { type: 'string' },
|
|
37
39
|
phases: { type: 'string' },
|
|
@@ -79,6 +81,8 @@ export function parseLoopArgs(argv) {
|
|
|
79
81
|
branch: values.branch,
|
|
80
82
|
baseBranch: values['base-branch'],
|
|
81
83
|
engine: values.engine,
|
|
84
|
+
model: values.model,
|
|
85
|
+
effort: values.effort,
|
|
82
86
|
overrideEngine: values['override-engine'],
|
|
83
87
|
config: values.config,
|
|
84
88
|
phases: values.phases ? values.phases.split(',').map((phase) => phase.trim()).filter(Boolean) : undefined,
|
|
@@ -132,6 +136,8 @@ export function usage() {
|
|
|
132
136
|
' -b, --branch <name> Branch to build on (default: <branchPrefix><name>)',
|
|
133
137
|
' --base-branch <branch> Base to branch from and PR against (default: baseBranch config)',
|
|
134
138
|
' -e, --engine <name> Default engine: claude | codex | agy | gemini',
|
|
139
|
+
' -m, --model <name> Model for the default engine (overrides config; switching engines without it uses the new engine default)',
|
|
140
|
+
' --effort <level> Reasoning effort for the default engine (overrides config; ignored by engines without effort)',
|
|
135
141
|
' --override-engine With --resume and --engine, replace saved agent executables',
|
|
136
142
|
' --config <path> Load loop configuration from this file (also overrides saved config on resume)',
|
|
137
143
|
' --phases a,b,c Override the configured phase list',
|
package/package.json
CHANGED
package/src/pipeline.mjs
CHANGED
|
@@ -44,12 +44,23 @@ export function buildTaskContext({ task, taskFile }) {
|
|
|
44
44
|
return '';
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function overrideSavedEngines(agentSettings, engine, customAdapters) {
|
|
47
|
+
function overrideSavedEngines(agentSettings, { engine, model, effort }, customAdapters) {
|
|
48
|
+
// Switching engine drops the saved model/effort: they are vendor-specific and
|
|
49
|
+
// meaningless to the new engine (see the --engine override above). --model and
|
|
50
|
+
// --effort set them explicitly for the new engine; without them each phase
|
|
51
|
+
// falls back to the target engine's default. validateAgent rejects an effort
|
|
52
|
+
// the target engine cannot honor.
|
|
53
|
+
const target = validateAgent(
|
|
54
|
+
{ name: engine, ...(model ? { model } : {}), ...(effort ? { effort } : {}) },
|
|
55
|
+
customAdapters,
|
|
56
|
+
);
|
|
48
57
|
const overrides = {};
|
|
49
58
|
const overriddenSettings = Object.fromEntries(
|
|
50
59
|
Object.entries(agentSettings).map(([phaseName, agent]) => {
|
|
51
|
-
if (agent.name ===
|
|
52
|
-
|
|
60
|
+
if (agent.name === target.name && agent.model === target.model && agent.effort === target.effort) {
|
|
61
|
+
return [phaseName, agent];
|
|
62
|
+
}
|
|
63
|
+
const overridden = { ...target };
|
|
53
64
|
overrides[phaseName] = { from: agent, to: overridden };
|
|
54
65
|
return [phaseName, overridden];
|
|
55
66
|
}),
|
|
@@ -482,10 +493,10 @@ async function runAgent(phase, ctx, variables) {
|
|
|
482
493
|
log(` engine: ${agent.name}${agent.model ? ` model: ${agent.model}` : ''}${agent.effort ? ` effort: ${agent.effort}` : ''} prompt: ${path}`);
|
|
483
494
|
if (engineOverride) log(` resumed override: ${describeAgent(engineOverride.from)} → ${describeAgent(engineOverride.to)}`);
|
|
484
495
|
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
// cannot reach the real worktree
|
|
488
|
-
const agentCwd = artifactOnly ? ctx.state.dir : ctx.worktree;
|
|
496
|
+
// Read-only agents inspect the disposable source snapshot. Their writable
|
|
497
|
+
// added directories still point only at the run artifacts (and task file for
|
|
498
|
+
// review), so changing cwd cannot reach the real worktree.
|
|
499
|
+
const agentCwd = sourceSnapshot?.path ?? (artifactOnly ? ctx.state.dir : ctx.worktree);
|
|
489
500
|
// Only the verdict phase's prompt is instructed to edit the task file (its
|
|
490
501
|
// Code Review section), so only it is granted the external task-file repo as
|
|
491
502
|
// writable. Every other artifact-only phase — pr-description included — gets
|
|
@@ -611,7 +622,24 @@ export async function runLoop(options = {}) {
|
|
|
611
622
|
...(args.noWorktree ? { worktrees: false } : {}),
|
|
612
623
|
}, args.config ? resolve(cwd, args.config) : undefined);
|
|
613
624
|
if (args.engine && !args.overrideEngine) {
|
|
614
|
-
|
|
625
|
+
// A model/effort string is vendor-specific — `gpt-5.6-terra` means nothing
|
|
626
|
+
// to gemini — so switching to a different engine must not inherit the
|
|
627
|
+
// previous engine's model/effort. Carry them over only when the engine is
|
|
628
|
+
// unchanged; --model/--effort below can still set them explicitly.
|
|
629
|
+
const base = args.engine === config.engines.default.name
|
|
630
|
+
? config.engines.default
|
|
631
|
+
: { name: args.engine };
|
|
632
|
+
config.engines.default = validateAgent({ ...base, name: args.engine }, config.adapters);
|
|
633
|
+
}
|
|
634
|
+
if ((args.model || args.effort) && !args.overrideEngine) {
|
|
635
|
+
config.engines.default = validateAgent(
|
|
636
|
+
{
|
|
637
|
+
...config.engines.default,
|
|
638
|
+
...(args.model ? { model: args.model } : {}),
|
|
639
|
+
...(args.effort ? { effort: args.effort } : {}),
|
|
640
|
+
},
|
|
641
|
+
config.adapters,
|
|
642
|
+
);
|
|
615
643
|
}
|
|
616
644
|
if (args.overrideEngine && !args.resume) {
|
|
617
645
|
throw new Error('--override-engine requires --resume.');
|
|
@@ -735,7 +763,11 @@ export async function runLoop(options = {}) {
|
|
|
735
763
|
if (!savedAgentSettings) {
|
|
736
764
|
throw new Error('--override-engine requires saved agent settings from an earlier run.');
|
|
737
765
|
}
|
|
738
|
-
({ agentSettings, overrides: engineOverrides } = overrideSavedEngines(
|
|
766
|
+
({ agentSettings, overrides: engineOverrides } = overrideSavedEngines(
|
|
767
|
+
savedAgentSettings,
|
|
768
|
+
{ engine: args.engine, model: args.model, effort: args.effort },
|
|
769
|
+
config.adapters,
|
|
770
|
+
));
|
|
739
771
|
if (Object.keys(engineOverrides).length > 0) {
|
|
740
772
|
const engineOverride = {
|
|
741
773
|
at: new Date().toISOString(),
|
package/src/runner.mjs
CHANGED
|
@@ -57,6 +57,19 @@ async function clearPendingRepair(phase, state) {
|
|
|
57
57
|
await state.record({ pendingRepairs });
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function requiresCommitBaseline(phase) {
|
|
61
|
+
return hasPostcondition(phase, 'head-advanced') || hasPostcondition(phase, 'head-advanced-or-rebuttal');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function phaseHeadBefore(phase, ctx) {
|
|
65
|
+
if (ctx.dryRun) return null;
|
|
66
|
+
if (requiresCommitBaseline(phase)) {
|
|
67
|
+
return ctx.state.baselineFor(phase.name, () => ctx.git.revParse());
|
|
68
|
+
}
|
|
69
|
+
if (hasPostcondition(phase, 'head-unchanged')) return ctx.git.revParse();
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
60
73
|
async function invalidateGateCompletion(state, reviewedSha) {
|
|
61
74
|
const gate = state.manifest.entries.findLast((entry) => entry.role === 'gate'
|
|
62
75
|
&& entry.status === 'completed'
|
|
@@ -65,25 +78,32 @@ async function invalidateGateCompletion(state, reviewedSha) {
|
|
|
65
78
|
await state.record({ completed: state.data.completed.filter((name) => name !== gate.phase) });
|
|
66
79
|
}
|
|
67
80
|
|
|
81
|
+
function phaseAndRepairNames(phase) {
|
|
82
|
+
return [phase.name, ...(phase.repair ?? []).flatMap(phaseAndRepairNames)];
|
|
83
|
+
}
|
|
84
|
+
|
|
68
85
|
async function invalidateFollowingPhaseState(state, phases, phaseName) {
|
|
69
86
|
const phaseIndex = phases.findIndex((phase) => phase.name === phaseName);
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
if (phaseIndex < 0) return;
|
|
88
|
+
const invalidatedNames = new Set(phases.slice(phaseIndex).flatMap(phaseAndRepairNames));
|
|
72
89
|
|
|
73
|
-
const completed = state.data.completed.filter((name) => !
|
|
90
|
+
const completed = state.data.completed.filter((name) => !invalidatedNames.has(name));
|
|
74
91
|
const phaseState = Object.fromEntries(
|
|
75
|
-
Object.entries(state.data.phases ?? {}).filter(([name]) => !
|
|
92
|
+
Object.entries(state.data.phases ?? {}).filter(([name]) => !invalidatedNames.has(name)),
|
|
76
93
|
);
|
|
77
94
|
const pendingRepairs = Object.fromEntries(
|
|
78
|
-
Object.entries(state.data.pendingRepairs ?? {}).filter(([name]) => !
|
|
95
|
+
Object.entries(state.data.pendingRepairs ?? {}).filter(([name]) => !invalidatedNames.has(name)),
|
|
79
96
|
);
|
|
80
97
|
const rounds = Object.fromEntries(
|
|
81
|
-
Object.entries(state.data.rounds ?? {}).filter(([name]) => !
|
|
98
|
+
Object.entries(state.data.rounds ?? {}).filter(([name]) => !invalidatedNames.has(name)),
|
|
82
99
|
);
|
|
83
100
|
const reviewedShas = Object.fromEntries(
|
|
84
|
-
Object.entries(state.data.reviewedShas ?? {}).filter(([name]) => !
|
|
101
|
+
Object.entries(state.data.reviewedShas ?? {}).filter(([name]) => !invalidatedNames.has(name)),
|
|
102
|
+
);
|
|
103
|
+
const phaseBaselines = Object.fromEntries(
|
|
104
|
+
Object.entries(state.data.phaseBaselines ?? {}).filter(([name]) => !invalidatedNames.has(name)),
|
|
85
105
|
);
|
|
86
|
-
await state.record({ completed, phases: phaseState, pendingRepairs, rounds, reviewedShas });
|
|
106
|
+
await state.record({ completed, phases: phaseState, pendingRepairs, rounds, reviewedShas, phaseBaselines });
|
|
87
107
|
}
|
|
88
108
|
|
|
89
109
|
async function runRepairs(phase, ctx, baseVariables, pending, operations, { retainCheckpoint = false } = {}) {
|
|
@@ -131,9 +151,35 @@ async function runRepairs(phase, ctx, baseVariables, pending, operations, { reta
|
|
|
131
151
|
}));
|
|
132
152
|
}
|
|
133
153
|
} else {
|
|
134
|
-
const headBefore =
|
|
135
|
-
|
|
136
|
-
|
|
154
|
+
const headBefore = await phaseHeadBefore(repair, ctx);
|
|
155
|
+
const forceNotedRepair = ctx.resume && ctx.note?.targetPhase === phase.name;
|
|
156
|
+
if (!ctx.dryRun && ctx.resume && requiresCommitBaseline(repair) && !forceNotedRepair) {
|
|
157
|
+
const stalledReason = await codePhasePostcondition(repair, ctx, {
|
|
158
|
+
hasFindings: pending.verdict?.blocking?.length > 0,
|
|
159
|
+
headBefore,
|
|
160
|
+
round: pending.round,
|
|
161
|
+
});
|
|
162
|
+
if (!stalledReason) {
|
|
163
|
+
const outputSha = await ctx.git.revParse();
|
|
164
|
+
const responsePath = join(ctx.state.dir, `response-round-${pending.round}.md`);
|
|
165
|
+
await recordManifest(ctx, manifestEntry(repair, ctx, {
|
|
166
|
+
inputSha: headBefore,
|
|
167
|
+
outputSha,
|
|
168
|
+
round: pending.round,
|
|
169
|
+
artifacts: [
|
|
170
|
+
...(await fileExists(responsePath) ? [`response-round-${pending.round}.md`] : []),
|
|
171
|
+
],
|
|
172
|
+
status: 'completed',
|
|
173
|
+
}));
|
|
174
|
+
await ctx.state.record({
|
|
175
|
+
pendingRepairs: {
|
|
176
|
+
...ctx.state.data.pendingRepairs,
|
|
177
|
+
[phase.name]: { ...pending, nextRepair: index + 1 },
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
137
183
|
const repairCtx = ctx.note?.targetPhase === phase.name
|
|
138
184
|
? { ...ctx, note: { ...ctx.note, repairPhase: repair.name } }
|
|
139
185
|
: ctx;
|
|
@@ -616,8 +662,22 @@ export async function runPhases({
|
|
|
616
662
|
await state.markComplete(phase.name, { rounds: result.rounds });
|
|
617
663
|
continue;
|
|
618
664
|
}
|
|
619
|
-
const headBefore =
|
|
620
|
-
|
|
665
|
+
const headBefore = await phaseHeadBefore(phase, ctx);
|
|
666
|
+
if (!ctx.dryRun && args.resume && requiresCommitBaseline(phase) && !forceNotedPhase) {
|
|
667
|
+
const stalledReason = await codePhasePostcondition(phase, ctx, { headBefore });
|
|
668
|
+
if (!stalledReason) {
|
|
669
|
+
const outputSha = await ctx.git.revParse();
|
|
670
|
+
log(` ${phase.name} already satisfies its commit postcondition; skipping agent on resume`);
|
|
671
|
+
await recordManifest(ctx, manifestEntry(phase, ctx, {
|
|
672
|
+
inputSha: headBefore,
|
|
673
|
+
outputSha,
|
|
674
|
+
status: 'completed',
|
|
675
|
+
}));
|
|
676
|
+
await state.markComplete(phase.name);
|
|
677
|
+
summary.phases.push({ name: phase.name, ok: true });
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
621
681
|
const result = await withRetries(phase, () => runAgent(phase, ctx, variables));
|
|
622
682
|
const stalledReason = await codePhasePostcondition(phase, ctx, { headBefore });
|
|
623
683
|
if (stalledReason) {
|
package/src/state.mjs
CHANGED
|
@@ -181,6 +181,7 @@ export class RunState {
|
|
|
181
181
|
completed: [],
|
|
182
182
|
rounds: {},
|
|
183
183
|
reviewedShas: {},
|
|
184
|
+
phaseBaselines: {},
|
|
184
185
|
...seed,
|
|
185
186
|
schemaVersion: STATE_SCHEMA_VERSION,
|
|
186
187
|
};
|
|
@@ -386,6 +387,14 @@ export class RunState {
|
|
|
386
387
|
await this.save();
|
|
387
388
|
}
|
|
388
389
|
|
|
390
|
+
async baselineFor(name, compute) {
|
|
391
|
+
const phaseBaselines = this.data.phaseBaselines ?? {};
|
|
392
|
+
if (phaseBaselines[name]) return phaseBaselines[name];
|
|
393
|
+
const baseline = await compute();
|
|
394
|
+
await this.record({ phaseBaselines: { ...phaseBaselines, [name]: baseline } });
|
|
395
|
+
return baseline;
|
|
396
|
+
}
|
|
397
|
+
|
|
389
398
|
async record(patch) {
|
|
390
399
|
Object.assign(this.data, patch);
|
|
391
400
|
await this.save();
|
package/src/types.d.ts
CHANGED
|
@@ -167,6 +167,7 @@ export interface RunData {
|
|
|
167
167
|
completed: string[];
|
|
168
168
|
rounds: Record<string, number>;
|
|
169
169
|
reviewedShas: Record<string, string>;
|
|
170
|
+
phaseBaselines?: Record<string, string>;
|
|
170
171
|
phases?: Record<string, Record<string, any>>;
|
|
171
172
|
pendingRepairs?: Record<string, any>;
|
|
172
173
|
[key: string]: any;
|
|
@@ -184,6 +185,7 @@ export interface RunState {
|
|
|
184
185
|
verdictPath(round: number): string;
|
|
185
186
|
isComplete(name: string): boolean;
|
|
186
187
|
markComplete(name: string, details?: Record<string, unknown>): Promise<void>;
|
|
188
|
+
baselineFor(name: string, compute: () => Promise<string>): Promise<string>;
|
|
187
189
|
record(patch: Record<string, unknown>): Promise<void>;
|
|
188
190
|
saveManifest(manifest: Record<string, unknown>): Promise<void>;
|
|
189
191
|
}
|