@syntax-syllogism/aloop 0.6.1 → 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 +24 -0
- package/README.md +4 -3
- package/bin/loop.mjs +1 -1
- package/package.json +2 -1
- package/src/adapters.mjs +115 -2
- package/src/command.mjs +17 -3
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
|
|
@@ -183,6 +201,12 @@ History from before the standalone extraction (changesets format), preserved for
|
|
|
183
201
|
- Prompts ship with the package and are overridable per phase from
|
|
184
202
|
`.loop/prompts/` in the consuming project.
|
|
185
203
|
|
|
204
|
+
## [0.6.2] - 2026-09-16
|
|
205
|
+
|
|
206
|
+
### Fixed
|
|
207
|
+
|
|
208
|
+
- Resolve Windows executables to correct on-disk casing
|
|
209
|
+
|
|
186
210
|
## [0.5.2] - 2026-09-13
|
|
187
211
|
|
|
188
212
|
### Changed
|
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 `
|
|
89
|
-
CLI must already be installed and authenticated. Engine
|
|
90
|
-
an engine-specific `model` and `effort
|
|
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.
|
|
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
|
-
|
|
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
|
|
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') {
|
package/src/command.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { win32 as windowsPath } from 'node:path';
|
|
4
4
|
|
|
5
5
|
export function signalProcessGroup(pid, signal, {
|
|
@@ -63,7 +63,10 @@ function environmentValue(env, name) {
|
|
|
63
63
|
return key ? env[key] : undefined;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
export function resolveWindowsExecutable(command, env = process.env, {
|
|
66
|
+
export function resolveWindowsExecutable(command, env = process.env, {
|
|
67
|
+
fileExists = existsSync,
|
|
68
|
+
realPath = realpathSync.native,
|
|
69
|
+
} = {}) {
|
|
67
70
|
const pathEntries = (environmentValue(env, 'PATH') ?? '').split(';').filter(Boolean);
|
|
68
71
|
const pathExtensions = (environmentValue(env, 'PATHEXT') ?? WINDOWS_EXECUTABLE_EXTENSIONS.join(';'))
|
|
69
72
|
.split(';')
|
|
@@ -76,7 +79,18 @@ export function resolveWindowsExecutable(command, env = process.env, { fileExist
|
|
|
76
79
|
for (const base of bases) {
|
|
77
80
|
for (const extension of extensions) {
|
|
78
81
|
const candidate = `${base}${extension}`;
|
|
79
|
-
|
|
82
|
+
// Candidates are assembled from PATHEXT, which is upper-cased by convention
|
|
83
|
+
// (".EXE"), while the file on disk is usually "git.exe". existsSync matches
|
|
84
|
+
// case-insensitively, so a match here would otherwise be handed to spawn with
|
|
85
|
+
// the wrong casing — which fails with ENOENT under Git Bash / MSYS. Canonicalize
|
|
86
|
+
// to the real on-disk path so spawn receives the name that actually exists.
|
|
87
|
+
if (fileExists(candidate)) {
|
|
88
|
+
try {
|
|
89
|
+
return realPath(candidate);
|
|
90
|
+
} catch {
|
|
91
|
+
return candidate;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
80
94
|
}
|
|
81
95
|
}
|
|
82
96
|
return null;
|