@syntax-syllogism/aloop 0.8.2 → 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.2",
3
+ "version": "0.8.4",
4
4
  "description": "Syntax & Syllogism agentic loop runner.",
5
5
  "type": "module",
6
6
  "exports": {
package/src/adapters.mjs CHANGED
@@ -413,16 +413,26 @@ const geminiAdapter = {
413
413
  // until the process exits, so a multi-minute phase is indistinguishable from
414
414
  // a hang. stream-json emits an event per step, which `createRenderer` turns
415
415
  // back into readable lines — same reasoning as the claude adapter above.
416
+ //
417
+ // The prompt goes on stdin, not `--prompt`. Gemini's Windows launcher is a
418
+ // `.cmd`/`.ps1` shim, so on Git Bash aloop runs it through `cmd.exe /c`
419
+ // (see command.mjs windowsSpawnSpec). cmd.exe caps the whole command line at
420
+ // ~8191 chars and mangles newlines and metacharacters (`% ! & | < >`), so a
421
+ // real review prompt — work item plus instructions — arrives truncated or
422
+ // garbled and Gemini "ignores" it. stdin is a pipe cmd.exe never parses, so
423
+ // the prompt survives intact. `-p ""` still selects headless mode; Gemini
424
+ // documents `--prompt` as "appended to input on stdin", so an empty flag
425
+ // leaves the stdin prompt as the whole prompt.
416
426
  const canWrite = permissionLevel(permissions) === PERMISSIONS.WRITE_WORKTREE || artifactOnly;
417
427
  const args = [
418
- '--prompt', prompt,
428
+ '--prompt', '',
419
429
  '--approval-mode', canWrite ? 'yolo' : 'plan',
420
430
  '--skip-trust',
421
431
  '--output-format', 'stream-json',
422
432
  ];
423
433
  if (agent.model) args.push('--model', agent.model);
424
434
  for (const dir of addDirs) args.push('--include-directories', dir);
425
- return { command: 'gemini', args };
435
+ return { command: 'gemini', args, input: prompt };
426
436
  },
427
437
  createRenderer: () => createJsonlRenderer(renderGeminiEvent),
428
438
  };
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,16 +493,16 @@ 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
492
503
  // just its own run directory; it has no business touching the task file repo.
493
504
  const artifactOnlyDirs = phase.role === 'verdict' ? ctx.artifactDirs : [ctx.state.dir];
494
- const { command, args } = adapter.command({
505
+ const { command, args, input } = adapter.command({
495
506
  prompt,
496
507
  cwd: agentCwd,
497
508
  addDirs: worktreeWrite ? ctx.addDirs : [...artifactOnlyDirs, ...(sourceSnapshot ? [sourceSnapshot.path] : [])],
@@ -539,6 +550,10 @@ async function runAgent(phase, ctx, variables) {
539
550
  env: execution.env,
540
551
  timeoutMs: ctx.config.timeoutMs,
541
552
  activeProcessPath: ctx.activeProcessPath,
553
+ // Adapters that carry the prompt on stdin (gemini, to dodge the cmd.exe
554
+ // command-line limit on Windows) return it as `input`; a hermetic wrapper
555
+ // forwards stdin to the sandboxed process unchanged.
556
+ input,
542
557
  onOutput: (text, stream) => emit(stream === 'stderr' ? text : renderer.write(text)),
543
558
  });
544
559
  } catch (error) {
@@ -607,7 +622,24 @@ export async function runLoop(options = {}) {
607
622
  ...(args.noWorktree ? { worktrees: false } : {}),
608
623
  }, args.config ? resolve(cwd, args.config) : undefined);
609
624
  if (args.engine && !args.overrideEngine) {
610
- 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
+ );
611
643
  }
612
644
  if (args.overrideEngine && !args.resume) {
613
645
  throw new Error('--override-engine requires --resume.');
@@ -731,7 +763,11 @@ export async function runLoop(options = {}) {
731
763
  if (!savedAgentSettings) {
732
764
  throw new Error('--override-engine requires saved agent settings from an earlier run.');
733
765
  }
734
- ({ 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
+ ));
735
771
  if (Object.keys(engineOverrides).length > 0) {
736
772
  const engineOverride = {
737
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
@@ -37,6 +37,15 @@ function isActiveProcessAlive(activeProcess) {
37
37
  return isProcessGroupAlive(activeProcess?.processGroupId) || isProcessAlive(activeProcess?.pid);
38
38
  }
39
39
 
40
+ async function pathExists(path) {
41
+ try {
42
+ await access(path);
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
40
49
  async function readActiveProcess(dir) {
41
50
  try {
42
51
  return JSON.parse(await readFile(join(dir, 'active-command.json'), 'utf8'));
@@ -172,6 +181,7 @@ export class RunState {
172
181
  completed: [],
173
182
  rounds: {},
174
183
  reviewedShas: {},
184
+ phaseBaselines: {},
175
185
  ...seed,
176
186
  schemaVersion: STATE_SCHEMA_VERSION,
177
187
  };
@@ -238,7 +248,16 @@ export class RunState {
238
248
  await publishLock(path, lockToken, contents);
239
249
  return lockToken;
240
250
  } catch (error) {
241
- if (!['EEXIST', 'ENOTEMPTY'].includes(error.code)) throw error;
251
+ // POSIX rejects a rename onto the existing (non-empty) lock directory
252
+ // with EEXIST/ENOTEMPTY — the signal that the lock is already held.
253
+ // Windows rejects the same rename with EPERM (sometimes EACCES)
254
+ // instead, so without this a `--resume` that finds any prior lock dir
255
+ // crashes with "EPERM: operation not permitted, rename". Treat those as
256
+ // contention too, but only when the lock path is actually present, so a
257
+ // genuine permission failure still surfaces instead of spinning here.
258
+ const contended = ['EEXIST', 'ENOTEMPTY'].includes(error.code)
259
+ || (['EPERM', 'EACCES'].includes(error.code) && await pathExists(path));
260
+ if (!contended) throw error;
242
261
  }
243
262
 
244
263
  let existing;
@@ -368,6 +387,14 @@ export class RunState {
368
387
  await this.save();
369
388
  }
370
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
+
371
398
  async record(patch) {
372
399
  Object.assign(this.data, patch);
373
400
  await this.save();
package/src/types.d.ts CHANGED
@@ -70,6 +70,12 @@ export interface AdapterCommandOptions {
70
70
  export interface AdapterCommand {
71
71
  command: string;
72
72
  args: string[];
73
+ /**
74
+ * Optional stdin for the command. Adapters use it to carry the prompt off the
75
+ * argument vector (gemini does, to dodge the cmd.exe command-line limit on
76
+ * Windows) rather than passing it as an argv element.
77
+ */
78
+ input?: string;
73
79
  [key: string]: unknown;
74
80
  }
75
81
 
@@ -161,6 +167,7 @@ export interface RunData {
161
167
  completed: string[];
162
168
  rounds: Record<string, number>;
163
169
  reviewedShas: Record<string, string>;
170
+ phaseBaselines?: Record<string, string>;
164
171
  phases?: Record<string, Record<string, any>>;
165
172
  pendingRepairs?: Record<string, any>;
166
173
  [key: string]: any;
@@ -178,6 +185,7 @@ export interface RunState {
178
185
  verdictPath(round: number): string;
179
186
  isComplete(name: string): boolean;
180
187
  markComplete(name: string, details?: Record<string, unknown>): Promise<void>;
188
+ baselineFor(name: string, compute: () => Promise<string>): Promise<string>;
181
189
  record(patch: Record<string, unknown>): Promise<void>;
182
190
  saveManifest(manifest: Record<string, unknown>): Promise<void>;
183
191
  }