@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 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, readOnlyDirs, permissions, artifactOnly, agent })` function returns `{ command, args }`. For a read-only phase,
117
- `cwd` is always the run directory and `addDirs` contains only artifact roots;
118
- `readOnlyDirs` is an optional source-inspection input for adapters that can
119
- honor it. The runner supplies a permission level and restricts `addDirs`
120
- accordingly; the adapter must translate `permissions` into its own read-only
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syntax-syllogism/aloop",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "Syntax & Syllogism agentic loop runner.",
5
5
  "type": "module",
6
6
  "exports": {
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 === engine) return [phaseName, agent];
52
- const overridden = validateAgent({ ...agent, name: engine }, customAdapters);
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
- // Every read-only agent starts in the driver-owned artifact directory. The
486
- // source snapshot is disposable, so an adapter's writable added directories
487
- // cannot reach the real worktree while the agent can still inspect a Git tree.
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
- config.engines.default = validateAgent({ ...config.engines.default, name: args.engine }, config.adapters);
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(savedAgentSettings, args.engine, config.adapters));
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
- const followingNames = new Set(phases.slice(phaseIndex + 1).map((phase) => phase.name));
71
- if (!followingNames.size) return;
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) => !followingNames.has(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]) => !followingNames.has(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]) => !followingNames.has(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]) => !followingNames.has(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]) => !followingNames.has(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 = !ctx.dryRun && (hasPostcondition(repair, 'head-advanced') || hasPostcondition(repair, 'head-advanced-or-rebuttal'))
135
- ? await ctx.git.revParse()
136
- : null;
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 = !ctx.dryRun && (hasPostcondition(phase, 'head-advanced') || hasPostcondition(phase, 'head-advanced-or-rebuttal') || hasPostcondition(phase, 'head-unchanged'))
620
- ? await ctx.git.revParse() : null;
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
  }