@syntax-syllogism/aloop 0.6.2 → 0.7.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 CHANGED
@@ -5,6 +5,24 @@ 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
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ - **Gemini engine.** `gemini` joins `claude`, `codex`, and `agy` as a built-in
13
+ engine. Any phase can run on the Gemini CLI via `engines.*.name = 'gemini'` or
14
+ `--engine gemini`. Headless phases run with `--approval-mode` (`plan` when
15
+ read-only, `yolo` when writing), `--skip-trust` so a fresh worktree is
16
+ trusted, and `--output-format stream-json` so long phases stay watchable.
17
+ Gemini has no reasoning-effort flag, so a configured `effort` is ignored for
18
+ it.
19
+
20
+ ## [0.7.0] - 2026-09-18
21
+
22
+ ### Added
23
+
24
+ - Gemini is now available as a built-in engine
25
+
8
26
  ## [0.6.0] - 2026-09-14
9
27
 
10
28
  ### Added
package/README.md CHANGED
@@ -85,9 +85,10 @@ export default {
85
85
  };
86
86
  ```
87
87
 
88
- Supported built-in engines are `claude`, `codex`, and `agy`. The corresponding
89
- CLI must already be installed and authenticated. Engine descriptors may include
90
- an engine-specific `model` and `effort`. `--config` selects a configuration
88
+ Supported built-in engines are `claude`, `codex`, `agy`, and `gemini`. The
89
+ corresponding CLI must already be installed and authenticated. Engine
90
+ descriptors may include an engine-specific `model` and `effort` (Gemini has no
91
+ effort tier, so a configured `effort` is ignored for it). `--config` selects a configuration
91
92
  file outside the current repository; command-line phase and round options take
92
93
  precedence.
93
94
 
package/bin/loop.mjs CHANGED
@@ -115,7 +115,7 @@ export function usage() {
115
115
  ' -n, --name <slug> Explicit run identity/slug',
116
116
  ' -b, --branch <name> Branch to build on (default: <branchPrefix><name>)',
117
117
  ' --base-branch <branch> Base to branch from and PR against (default: baseBranch config)',
118
- ' -e, --engine <name> Default engine: claude | codex | agy',
118
+ ' -e, --engine <name> Default engine: claude | codex | agy | gemini',
119
119
  ' --override-engine With --resume and --engine, replace saved agent executables',
120
120
  ' --config <path> Load loop configuration from this file (also overrides saved config on resume)',
121
121
  ' --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.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Syntax & Syllogism agentic loop runner.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -31,6 +31,7 @@
31
31
  "claude",
32
32
  "codex",
33
33
  "antigravity",
34
+ "gemini",
34
35
  "opencode",
35
36
  "cli"
36
37
  ],
package/src/adapters.mjs CHANGED
@@ -71,6 +71,24 @@ function summarizeAgyParams(params) {
71
71
  return '';
72
72
  }
73
73
 
74
+ /**
75
+ * Summarize a Gemini tool call's parameters.
76
+ *
77
+ * Gemini's built-in tools use the descriptive keys the shared summarizer already
78
+ * knows (`command`, `file_path`, `path`, `pattern`, `url`, …); an MCP tool may
79
+ * use anything, so fall back to the first string value — robust to tools this
80
+ * list has never seen, the same shape as `summarizeAgyParams`.
81
+ */
82
+ function summarizeGeminiParams(params) {
83
+ if (!params || typeof params !== 'object') return '';
84
+ const known = summarizeToolInput(params);
85
+ if (known) return known;
86
+ for (const value of Object.values(params)) {
87
+ if (typeof value === 'string' && value.trim()) return condense(value);
88
+ }
89
+ return '';
90
+ }
91
+
74
92
  function cliVersion(command) {
75
93
  return async () => {
76
94
  const cwd = await mkdtemp(join(tmpdir(), 'aloop-version-'));
@@ -163,6 +181,63 @@ export function renderAgyEvent(event) {
163
181
  return '';
164
182
  }
165
183
 
184
+ /**
185
+ * Turn one Gemini stream-json event into the line a watching human wants.
186
+ *
187
+ * Gemini tags each event with `type` (like Claude, unlike agy's `event`). The
188
+ * headless stream emits `init` (session), `message` (user/assistant turns —
189
+ * assistant text streams as deltas), `tool_use` (a tool the model invoked, with
190
+ * `tool_name`/`parameters`), `tool_result` (its outcome, `status` success|error
191
+ * with an `error.message`), `error` (non-fatal warnings and system errors), and
192
+ * `result` (final status, token counts under `stats`). Tool activity and tool
193
+ * or system failures are surfaced so a long phase stays watchable; a successful
194
+ * tool result is not — its full output would bury the log, exactly as the
195
+ * claude and agy renderers omit theirs. An unrecognized shape renders as nothing
196
+ * rather than raw JSON, so the enormous init payload and any event a future
197
+ * release adds stay out of the log.
198
+ */
199
+ export function renderGeminiEvent(event) {
200
+ if (event.type === 'init') {
201
+ const id = String(event.session_id ?? '').slice(0, 8);
202
+ const model = event.model;
203
+ return ` · session ${id}${model ? ` model ${model}` : ''}\n`;
204
+ }
205
+ if (event.type === 'message') {
206
+ // Assistant text arrives as deltas; stream them through verbatim. The user
207
+ // turn is the prompt aloop already wrote, so echoing it back is noise.
208
+ if (event.role !== 'assistant' || typeof event.content !== 'string') return '';
209
+ return event.content;
210
+ }
211
+ if (event.type === 'tool_use') {
212
+ const detail = summarizeGeminiParams(event.parameters);
213
+ return ` → ${event.tool_name ?? 'tool'}${detail ? ` ${detail}` : ''}\n`;
214
+ }
215
+ if (event.type === 'tool_result') {
216
+ // Only failures are worth a line; a successful result's `output` is the
217
+ // tool's full stdout and belongs in the phase's files, not the run log.
218
+ if (event.status !== 'error') return '';
219
+ const message = event.error?.message ?? event.output ?? 'tool call failed';
220
+ return ` ✗ ${condense(String(message), 160)}\n`;
221
+ }
222
+ if (event.type === 'error') {
223
+ // Non-fatal warnings and system errors — a warning is marked, not dropped,
224
+ // so a stall or a blocked turn is visible while the phase runs.
225
+ const mark = event.severity === 'warning' ? '⚠' : '✗';
226
+ return ` ${mark} ${condense(String(event.message ?? 'error'), 160)}\n`;
227
+ }
228
+ if (event.type === 'result') {
229
+ const stats = event.stats ?? {};
230
+ const calls = stats.tool_calls ? ` ${stats.tool_calls} tool call${stats.tool_calls === 1 ? '' : 's'}` : '';
231
+ const seconds = stats.duration_ms ? ` ${Math.round(stats.duration_ms / 1000)}s` : '';
232
+ if (event.status && event.status !== 'success') {
233
+ const why = event.error?.message ? `: ${condense(String(event.error.message), 160)}` : '';
234
+ return ` ✗ ${String(event.status).toLowerCase()}${why}${calls}${seconds}\n`;
235
+ }
236
+ return ` ✓ done${calls}${seconds}\n`;
237
+ }
238
+ return '';
239
+ }
240
+
166
241
  /**
167
242
  * Line-buffer a JSONL stream and render each complete event.
168
243
  *
@@ -175,7 +250,9 @@ export function createJsonlRenderer(renderEvent) {
175
250
  let usage = null;
176
251
  const recordUsage = (event) => {
177
252
  if (!event || typeof event !== 'object') return;
178
- const source = event.usage ?? event.result?.usage ?? event;
253
+ // Gemini reports token counts under `stats` rather than `usage`; check it
254
+ // before falling back to the event itself so its totals are not lost.
255
+ const source = event.usage ?? event.result?.usage ?? event.stats ?? event.result?.stats ?? event;
179
256
  if (!source || typeof source !== 'object') return;
180
257
  const tokenValues = [
181
258
  source.tokens,
@@ -305,7 +382,43 @@ const agyAdapter = {
305
382
  createRenderer: () => createJsonlRenderer(renderAgyEvent),
306
383
  };
307
384
 
308
- const adapters = { claude: claudeAdapter, codex: codexAdapter, agy: agyAdapter };
385
+ const geminiAdapter = {
386
+ name: 'gemini',
387
+ version: cliVersion('gemini'),
388
+ // Gemini CLI exposes no reasoning-effort flag, so this adapter declares no
389
+ // `efforts`: a configured effort is simply not passed through (see docs).
390
+ command({ prompt, addDirs, permissions, artifactOnly = false, agent = {} }) {
391
+ // `yolo`, not `plan`/`auto_edit`: in headless `--prompt` mode Gemini cannot
392
+ // interactively approve the shell tools a worktree-writing phase needs to
393
+ // build, test, and commit. `plan` is read-only and `auto_edit` auto-approves
394
+ // only edit tools, so either one auto-denies the first shell call and the
395
+ // phase "completes" instantly with an empty log. `yolo` auto-approves every
396
+ // tool — Gemini's equivalent of the claude adapter's `--permission-mode
397
+ // auto` and agy's `--dangerously-skip-permissions`.
398
+ //
399
+ // `--skip-trust`: a fresh worktree is an untrusted folder, and Gemini
400
+ // refuses to run headlessly in one (it prints a trust warning and does
401
+ // nothing). This trusts the workspace for this one invocation only.
402
+ //
403
+ // stream-json, not the default `text`: text output withholds every byte
404
+ // until the process exits, so a multi-minute phase is indistinguishable from
405
+ // a hang. stream-json emits an event per step, which `createRenderer` turns
406
+ // back into readable lines — same reasoning as the claude adapter above.
407
+ const canWrite = permissionLevel(permissions) === PERMISSIONS.WRITE_WORKTREE || artifactOnly;
408
+ const args = [
409
+ '--prompt', prompt,
410
+ '--approval-mode', canWrite ? 'yolo' : 'plan',
411
+ '--skip-trust',
412
+ '--output-format', 'stream-json',
413
+ ];
414
+ if (agent.model) args.push('--model', agent.model);
415
+ for (const dir of addDirs) args.push('--include-directories', dir);
416
+ return { command: 'gemini', args };
417
+ },
418
+ createRenderer: () => createJsonlRenderer(renderGeminiEvent),
419
+ };
420
+
421
+ const adapters = { claude: claudeAdapter, codex: codexAdapter, agy: agyAdapter, gemini: geminiAdapter };
309
422
 
310
423
  function validateConfiguredAdapter(name, adapter) {
311
424
  if (!adapter || typeof adapter !== 'object' || Array.isArray(adapter) || typeof adapter.command !== 'function') {