@syntax-syllogism/aloop 0.6.2 → 0.8.1

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,18 @@ 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.7.0] - 2026-09-18
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
+
8
20
  ## [0.6.0] - 2026-09-14
9
21
 
10
22
  ### Added
@@ -183,6 +195,26 @@ History from before the standalone extraction (changesets format), preserved for
183
195
  - Prompts ship with the package and are overridable per phase from
184
196
  `.loop/prompts/` in the consuming project.
185
197
 
198
+ ## [0.8.0] - 2026-09-20
199
+
200
+ ### Added
201
+
202
+ - Eval harness for running code across multiple models
203
+ - JSDoc type checking for validation
204
+ - Live TUI renderer for interactive aloop runs
205
+ - aloop init command for project initialization
206
+ - Guided resume capability to repair failing gates
207
+
208
+ ### Changed
209
+
210
+ - TUI renderer now runs even with --yes flag
211
+
212
+ ### Fixed
213
+
214
+ - JSDoc type contracts bound to production boundaries
215
+ - Fixed TUI rendering performance with diff-stat caching
216
+ - Resume repair and downstream attestation restored
217
+
186
218
  ## [0.6.2] - 2026-09-16
187
219
 
188
220
  ### Fixed
package/README.md CHANGED
@@ -28,7 +28,13 @@ Interactive confirmations use the Git Bash/Windows console when available. Use
28
28
 
29
29
  ## Quick start
30
30
 
31
- From a git repository with a `loop.config.mjs` (or with the defaults):
31
+ From a git repository, create the editable configuration and prompt overrides:
32
+
33
+ ```sh
34
+ aloop init
35
+ ```
36
+
37
+ Review the generated `loop.config.mjs`, then start a task:
32
38
 
33
39
  ```sh
34
40
  aloop --task "Add request tracing" --name request-tracing
@@ -67,7 +73,8 @@ complete guide, manifest schema, and verdict contract.
67
73
 
68
74
  ## Configuration
69
75
 
70
- Create `loop.config.mjs` in the repository being operated on:
76
+ `aloop init` creates a commented `loop.config.mjs` in the repository being
77
+ operated on. Adjust it as needed; for example, a configuration can be:
71
78
 
72
79
  ```js
73
80
  export default {
@@ -85,9 +92,10 @@ export default {
85
92
  };
86
93
  ```
87
94
 
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
95
+ Supported built-in engines are `claude`, `codex`, `agy`, and `gemini`. The
96
+ corresponding CLI must already be installed and authenticated. Engine
97
+ descriptors may include an engine-specific `model` and `effort` (Gemini has no
98
+ effort tier, so a configured `effort` is ignored for it). `--config` selects a configuration
91
99
  file outside the current repository; command-line phase and round options take
92
100
  precedence.
93
101
 
@@ -124,12 +132,13 @@ placeholder is unresolved.
124
132
  For repositories using Markdown work items, install the bundled preset:
125
133
 
126
134
  ```sh
127
- mkdir -p .loop/prompts
128
- cp -r node_modules/@syntax-syllogism/aloop/presets/work-item/prompts/. .loop/prompts/
135
+ aloop init --preset work-item
129
136
  ```
130
137
 
131
- The preset includes prompts and a sample configuration. Adapt it to the
132
- repository's branches and gates before use.
138
+ This creates the preset's sample configuration and prompt overrides. Adapt its
139
+ branches and gates before use. `aloop init` also copies the default prompts, so
140
+ you can edit any `.loop/prompts/<phase>.md` immediately; see
141
+ [`docs/loop.md`](docs/loop.md#getting-started) for overwrite and preset details.
133
142
 
134
143
  ## Building up to unattended runs
135
144
 
@@ -140,6 +149,13 @@ Use this progression:
140
149
  3. Exercise the review loop with `--max-rounds 2`.
141
150
  4. Add `-y` only for task shapes whose behavior is already understood.
142
151
 
152
+ ## Evaluation harness
153
+
154
+ `aloop-eval <spec.mjs>` runs a task corpus across a model/config matrix, each
155
+ cell in its own throwaway repository, and reports completion, escaped
156
+ defects, convergence, and cost per cell and per config. See
157
+ [`docs/eval.md`](docs/eval.md) for the spec file shape and reported fields.
158
+
143
159
  ## Development
144
160
 
145
161
  ```sh
package/bin/eval.mjs ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parseArgs } from 'node:util';
4
+ import { resolve } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { isMainEntrypoint } from '../src/entrypoint.mjs';
7
+ import { formatEvalSummaryTable, formatEvalTable, runEval, summarizeEval } from '../src/eval.mjs';
8
+
9
+ export function usage() {
10
+ return [
11
+ 'Usage: aloop-eval <spec.mjs> [options]',
12
+ '',
13
+ 'Runs the task corpus and model/config matrix exported by <spec.mjs>',
14
+ '(a default export, or named `corpus` and `matrix` exports) through the',
15
+ 'loop and reports completion, escaped defects, convergence, cost, and time.',
16
+ '',
17
+ 'Options:',
18
+ ' --json Emit machine-readable JSON instead of tables',
19
+ ].join('\n');
20
+ }
21
+
22
+ export async function runEvalCli(argv, { cwd = process.cwd(), output = console } = {}) {
23
+ const { values, positionals } = parseArgs({
24
+ args: argv,
25
+ options: { json: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } },
26
+ allowPositionals: true,
27
+ strict: true,
28
+ });
29
+ if (values.help || !positionals.length) {
30
+ output.log(usage());
31
+ return null;
32
+ }
33
+ const [specPath] = positionals;
34
+ const spec = await import(pathToFileURL(resolve(cwd, specPath)).href);
35
+ const { corpus, matrix } = spec.default ?? spec;
36
+ if (!Array.isArray(corpus) || !Array.isArray(matrix)) {
37
+ throw new Error(`${specPath} must export a task \`corpus\` array and a config \`matrix\` array.`);
38
+ }
39
+ const results = await runEval({ corpus, matrix });
40
+ const summaries = summarizeEval(results);
41
+ if (values.json) {
42
+ output.log(JSON.stringify({ results, summaries }, null, 2));
43
+ } else {
44
+ output.log(formatEvalTable(results));
45
+ output.log('');
46
+ output.log(formatEvalSummaryTable(summaries));
47
+ }
48
+ return { results, summaries };
49
+ }
50
+
51
+ if (isMainEntrypoint(import.meta.url)) {
52
+ try {
53
+ await runEvalCli(process.argv.slice(2));
54
+ } catch (error) {
55
+ console.error(error.message);
56
+ process.exitCode = 1;
57
+ }
58
+ }
package/bin/loop.mjs CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  doctor,
11
11
  getAggregateMetrics,
12
12
  getRun,
13
+ init,
13
14
  inspectRun,
14
15
  listRuns,
15
16
  resolveRunsDir,
@@ -17,7 +18,7 @@ import {
17
18
  import { computeRunMetrics } from '../src/metrics.mjs';
18
19
  import { runLoop } from '../src/pipeline.mjs';
19
20
 
20
- const commands = new Set(['run', 'list', 'status', 'inspect', 'cancel', 'clean', 'doctor', 'metrics']);
21
+ const commands = new Set(['run', 'list', 'status', 'inspect', 'cancel', 'clean', 'doctor', 'metrics', 'init']);
21
22
 
22
23
  export function parseLoopArgs(argv) {
23
24
  const command = commands.has(argv[0]) ? argv[0] : 'run';
@@ -37,12 +38,17 @@ export function parseLoopArgs(argv) {
37
38
  'max-rounds': { type: 'string' },
38
39
  from: { type: 'string' },
39
40
  resume: { type: 'boolean' },
41
+ note: { type: 'string' },
42
+ 'note-file': { type: 'string' },
40
43
  yes: { type: 'boolean', short: 'y' },
41
44
  'no-worktree': { type: 'boolean' },
45
+ 'no-tui': { type: 'boolean' },
42
46
  'dry-run': { type: 'boolean' },
43
47
  json: { type: 'boolean' },
44
48
  metrics: { type: 'boolean' },
45
49
  'older-than': { type: 'string' },
50
+ preset: { type: 'string' },
51
+ force: { type: 'boolean' },
46
52
  help: { type: 'boolean', short: 'h' },
47
53
  },
48
54
  allowPositionals: true,
@@ -79,12 +85,17 @@ export function parseLoopArgs(argv) {
79
85
  maxRounds,
80
86
  from: values.from,
81
87
  resume: values.resume,
88
+ note: values.note,
89
+ noteFile: values['note-file'],
82
90
  yes: values.yes,
83
91
  noWorktree: values['no-worktree'],
92
+ noTui: values['no-tui'],
84
93
  dryRun: values['dry-run'],
85
94
  json: values.json,
86
95
  metrics: values.metrics,
87
96
  olderThanDays,
97
+ preset: values.preset,
98
+ force: values.force,
88
99
  };
89
100
  }
90
101
 
@@ -100,6 +111,7 @@ export function usage() {
100
111
  ' cancel <name> Stop a run and release its lock',
101
112
  ' clean Preview/remove completed runs older than 30 days',
102
113
  ' doctor Check configuration and local tooling',
114
+ ' init Scaffold loop.config.mjs and .loop/prompts',
103
115
  ' run Start or resume a run (the default)',
104
116
  '',
105
117
  'Operational options:',
@@ -109,20 +121,27 @@ export function usage() {
109
121
  ' -y, --yes Confirm destructive clean operations',
110
122
  ' --dry-run Preview clean operations without changing files',
111
123
  '',
124
+ 'Init options:',
125
+ ' --preset <name> Seed init from a bundled preset (e.g. work-item)',
126
+ ' --force Overwrite existing scaffold files',
127
+ '',
112
128
  'Run options:',
113
129
  ' -t, --task <text> Inline task description',
114
130
  ' -f, --task-file <path> Path to a task/plan/spec file',
115
131
  ' -n, --name <slug> Explicit run identity/slug',
116
132
  ' -b, --branch <name> Branch to build on (default: <branchPrefix><name>)',
117
133
  ' --base-branch <branch> Base to branch from and PR against (default: baseBranch config)',
118
- ' -e, --engine <name> Default engine: claude | codex | agy',
134
+ ' -e, --engine <name> Default engine: claude | codex | agy | gemini',
119
135
  ' --override-engine With --resume and --engine, replace saved agent executables',
120
136
  ' --config <path> Load loop configuration from this file (also overrides saved config on resume)',
121
137
  ' --phases a,b,c Override the configured phase list',
122
138
  ' --max-rounds <n> Cap on review/repair rounds',
123
139
  ' --from <phase> Start at this phase',
124
140
  ' --resume Skip phases already recorded complete',
141
+ ' --note <text> Give the resumed phase an authoritative instruction',
142
+ ' --note-file <path> Read the instruction from a file (--note wins)',
125
143
  ' --no-worktree Work in the current checkout instead of a worktree',
144
+ ' --no-tui Force plain streaming output (TUI is on by default on a TTY)',
126
145
  ' -y, --yes Run unattended (no per-phase confirmation; required without a terminal)',
127
146
  ' --dry-run Print the plan and rendered prompts, run nothing',
128
147
  ].join('\n');
@@ -258,6 +277,16 @@ export async function runOperationalCommand(args, { cwd = process.cwd(), output
258
277
  }
259
278
  return result;
260
279
  }
280
+ if (args.command === 'init') {
281
+ const result = await init({ cwd, preset: args.preset, force: args.force });
282
+ if (args.json) output.log(JSON.stringify(result, null, 2));
283
+ else {
284
+ for (const file of result.created) output.log(` create ${file}`);
285
+ for (const file of result.skipped) output.log(` skip ${file} (exists)`);
286
+ output.log(result.preset ? `Initialized aloop with preset "${result.preset}".` : 'Initialized aloop.');
287
+ }
288
+ return result;
289
+ }
261
290
  throw new Error(`Unknown command: ${args.command}`);
262
291
  }
263
292
 
@@ -270,7 +299,7 @@ if (isMainEntrypoint(import.meta.url)) {
270
299
  await runLoop({ args });
271
300
  } else {
272
301
  const result = await runOperationalCommand(args);
273
- if (args.command === 'doctor' && !result.ok) process.exitCode = 1;
302
+ if (args.command === 'doctor' && !(/** @type {any} */ (result)).ok) process.exitCode = 1;
274
303
  }
275
304
  } catch (error) {
276
305
  console.error(error.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syntax-syllogism/aloop",
3
- "version": "0.6.2",
3
+ "version": "0.8.1",
4
4
  "description": "Syntax & Syllogism agentic loop runner.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,7 +13,8 @@
13
13
  "node": ">=22"
14
14
  },
15
15
  "bin": {
16
- "aloop": "bin/loop.mjs"
16
+ "aloop": "bin/loop.mjs",
17
+ "aloop-eval": "bin/eval.mjs"
17
18
  },
18
19
  "files": [
19
20
  "bin",
@@ -31,6 +32,7 @@
31
32
  "claude",
32
33
  "codex",
33
34
  "antigravity",
35
+ "gemini",
34
36
  "opencode",
35
37
  "cli"
36
38
  ],
@@ -47,6 +49,11 @@
47
49
  "access": "public"
48
50
  },
49
51
  "scripts": {
50
- "test": "node --test"
52
+ "test": "node --test",
53
+ "typecheck": "tsc -p tsconfig.json"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^22.20.4",
57
+ "typescript": "^5.9.3"
51
58
  }
52
59
  }
@@ -18,14 +18,16 @@ branches, engines, phases, and `npm test` gate. Adjust it for your repository.
18
18
 
19
19
  ## Installation
20
20
 
21
- Install the package, create `.loop/prompts/`, and copy the preset prompts into
22
- the project override directory:
21
+ Install the package, then initialize the project with the preset:
23
22
 
24
23
  ```sh
25
- mkdir -p .loop/prompts
26
- cp -r node_modules/@syntax-syllogism/aloop/presets/work-item/prompts/. .loop/prompts/
24
+ aloop init --preset work-item
27
25
  ```
28
26
 
29
- The runner loads project prompt overrides before its packaged defaults. Keep the
30
- sample config as a starting point for `loop.config.mjs` if its settings match
31
- your repository.
27
+ This writes the sample config and preset prompts into your project, with the
28
+ packaged defaults filling any prompts the preset does not provide. The runner
29
+ loads project prompt overrides before its packaged defaults.
30
+
31
+ If you prefer to wire it up by hand, create `.loop/prompts/` and copy the
32
+ contents of `node_modules/@syntax-syllogism/aloop/presets/work-item/prompts/`
33
+ there; copy `loop.config.mjs` separately if its settings match your repository.
@@ -0,0 +1,17 @@
1
+ {{TASK_CONTEXT}}
2
+
3
+ The deterministic gate is failing. Work in `{{REPO}}` on branch `{{BRANCH}}`
4
+ and make the gate pass by fixing the underlying implementation.
5
+
6
+ ## Gate status
7
+
8
+ {{GATE_STATUS}}
9
+
10
+ ## Gate commands
11
+
12
+ {{GATE_COMMANDS}}
13
+
14
+ Do not delete, skip, or weaken tests or gate commands to make the gate pass.
15
+ Make only the changes needed to correct the failure, run the relevant tests and
16
+ the gate commands, then commit the repair with a conventional commit message.
17
+ Keep the worktree clean for the re-check.
package/src/adapters.mjs CHANGED
@@ -1,3 +1,7 @@
1
+ /** @typedef {import('./types.js').Adapter} Adapter */
2
+ /** @typedef {import('./types.js').Config} Config */
3
+ /** @typedef {import('./types.js').Engine} Engine */
4
+
1
5
  /**
2
6
  * Engine adapters.
3
7
  *
@@ -71,6 +75,24 @@ function summarizeAgyParams(params) {
71
75
  return '';
72
76
  }
73
77
 
78
+ /**
79
+ * Summarize a Gemini tool call's parameters.
80
+ *
81
+ * Gemini's built-in tools use the descriptive keys the shared summarizer already
82
+ * knows (`command`, `file_path`, `path`, `pattern`, `url`, …); an MCP tool may
83
+ * use anything, so fall back to the first string value — robust to tools this
84
+ * list has never seen, the same shape as `summarizeAgyParams`.
85
+ */
86
+ function summarizeGeminiParams(params) {
87
+ if (!params || typeof params !== 'object') return '';
88
+ const known = summarizeToolInput(params);
89
+ if (known) return known;
90
+ for (const value of Object.values(params)) {
91
+ if (typeof value === 'string' && value.trim()) return condense(value);
92
+ }
93
+ return '';
94
+ }
95
+
74
96
  function cliVersion(command) {
75
97
  return async () => {
76
98
  const cwd = await mkdtemp(join(tmpdir(), 'aloop-version-'));
@@ -163,6 +185,63 @@ export function renderAgyEvent(event) {
163
185
  return '';
164
186
  }
165
187
 
188
+ /**
189
+ * Turn one Gemini stream-json event into the line a watching human wants.
190
+ *
191
+ * Gemini tags each event with `type` (like Claude, unlike agy's `event`). The
192
+ * headless stream emits `init` (session), `message` (user/assistant turns —
193
+ * assistant text streams as deltas), `tool_use` (a tool the model invoked, with
194
+ * `tool_name`/`parameters`), `tool_result` (its outcome, `status` success|error
195
+ * with an `error.message`), `error` (non-fatal warnings and system errors), and
196
+ * `result` (final status, token counts under `stats`). Tool activity and tool
197
+ * or system failures are surfaced so a long phase stays watchable; a successful
198
+ * tool result is not — its full output would bury the log, exactly as the
199
+ * claude and agy renderers omit theirs. An unrecognized shape renders as nothing
200
+ * rather than raw JSON, so the enormous init payload and any event a future
201
+ * release adds stay out of the log.
202
+ */
203
+ export function renderGeminiEvent(event) {
204
+ if (event.type === 'init') {
205
+ const id = String(event.session_id ?? '').slice(0, 8);
206
+ const model = event.model;
207
+ return ` · session ${id}${model ? ` model ${model}` : ''}\n`;
208
+ }
209
+ if (event.type === 'message') {
210
+ // Assistant text arrives as deltas; stream them through verbatim. The user
211
+ // turn is the prompt aloop already wrote, so echoing it back is noise.
212
+ if (event.role !== 'assistant' || typeof event.content !== 'string') return '';
213
+ return event.content;
214
+ }
215
+ if (event.type === 'tool_use') {
216
+ const detail = summarizeGeminiParams(event.parameters);
217
+ return ` → ${event.tool_name ?? 'tool'}${detail ? ` ${detail}` : ''}\n`;
218
+ }
219
+ if (event.type === 'tool_result') {
220
+ // Only failures are worth a line; a successful result's `output` is the
221
+ // tool's full stdout and belongs in the phase's files, not the run log.
222
+ if (event.status !== 'error') return '';
223
+ const message = event.error?.message ?? event.output ?? 'tool call failed';
224
+ return ` ✗ ${condense(String(message), 160)}\n`;
225
+ }
226
+ if (event.type === 'error') {
227
+ // Non-fatal warnings and system errors — a warning is marked, not dropped,
228
+ // so a stall or a blocked turn is visible while the phase runs.
229
+ const mark = event.severity === 'warning' ? '⚠' : '✗';
230
+ return ` ${mark} ${condense(String(event.message ?? 'error'), 160)}\n`;
231
+ }
232
+ if (event.type === 'result') {
233
+ const stats = event.stats ?? {};
234
+ const calls = stats.tool_calls ? ` ${stats.tool_calls} tool call${stats.tool_calls === 1 ? '' : 's'}` : '';
235
+ const seconds = stats.duration_ms ? ` ${Math.round(stats.duration_ms / 1000)}s` : '';
236
+ if (event.status && event.status !== 'success') {
237
+ const why = event.error?.message ? `: ${condense(String(event.error.message), 160)}` : '';
238
+ return ` ✗ ${String(event.status).toLowerCase()}${why}${calls}${seconds}\n`;
239
+ }
240
+ return ` ✓ done${calls}${seconds}\n`;
241
+ }
242
+ return '';
243
+ }
244
+
166
245
  /**
167
246
  * Line-buffer a JSONL stream and render each complete event.
168
247
  *
@@ -175,7 +254,9 @@ export function createJsonlRenderer(renderEvent) {
175
254
  let usage = null;
176
255
  const recordUsage = (event) => {
177
256
  if (!event || typeof event !== 'object') return;
178
- const source = event.usage ?? event.result?.usage ?? event;
257
+ // Gemini reports token counts under `stats` rather than `usage`; check it
258
+ // before falling back to the event itself so its totals are not lost.
259
+ const source = event.usage ?? event.result?.usage ?? event.stats ?? event.result?.stats ?? event;
179
260
  if (!source || typeof source !== 'object') return;
180
261
  const tokenValues = [
181
262
  source.tokens,
@@ -228,15 +309,17 @@ export function createJsonlRenderer(renderEvent) {
228
309
  }
229
310
 
230
311
  /** A renderer for engines whose stdout is already meant to be read. */
312
+ /** @returns {import('./types.js').Renderer} */
231
313
  export function passthroughRenderer() {
232
314
  return { write: (text) => text, end: () => '' };
233
315
  }
234
316
 
317
+ /** @type {Adapter} */
235
318
  const claudeAdapter = {
236
319
  name: 'claude',
237
320
  version: cliVersion('claude'),
238
321
  efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
239
- command({ prompt, addDirs, permissions, artifactOnly = false, agent = {} }) {
322
+ command({ prompt, addDirs, permissions, artifactOnly = false, agent = /** @type {import('./types.js').Agent} */ ({}) }) {
240
323
  // `auto`, not `acceptEdits`, is used for worktree-writing and
241
324
  // artifact-only phases: under acceptEdits a non-interactive `-p` run
242
325
  // auto-denies every Bash call, because there is no one to answer the
@@ -257,11 +340,12 @@ const claudeAdapter = {
257
340
  createRenderer: () => createJsonlRenderer(renderClaudeEvent),
258
341
  };
259
342
 
343
+ /** @type {Adapter} */
260
344
  const codexAdapter = {
261
345
  name: 'codex',
262
346
  version: cliVersion('codex'),
263
347
  efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
264
- command({ prompt, cwd, addDirs, permissions, artifactOnly = false, agent = {} }) {
348
+ command({ prompt, cwd, addDirs, permissions, artifactOnly = false, agent = /** @type {import('./types.js').Agent} */ ({}) }) {
265
349
  const canWrite = permissionLevel(permissions) === PERMISSIONS.WRITE_WORKTREE || artifactOnly;
266
350
  const sandbox = canWrite ? 'workspace-write' : 'read-only';
267
351
  const args = ['exec', prompt, '--sandbox', sandbox, '--cd', cwd];
@@ -272,11 +356,12 @@ const codexAdapter = {
272
356
  },
273
357
  };
274
358
 
359
+ /** @type {Adapter} */
275
360
  const agyAdapter = {
276
361
  name: 'agy',
277
362
  version: cliVersion('agy'),
278
363
  efforts: ['low', 'medium', 'high'],
279
- command({ prompt, addDirs, timeoutMs, permissions, artifactOnly = false, agent = {} }) {
364
+ command({ prompt, addDirs, timeoutMs, permissions, artifactOnly = false, agent = /** @type {import('./types.js').Agent} */ ({}) }) {
280
365
  // `--dangerously-skip-permissions`, not bare `accept-edits`: in headless
281
366
  // `--print` mode agy cannot prompt for the `command` permission its Bash-
282
367
  // style tools need, so it auto-denies the first one and exits 0 having done
@@ -305,7 +390,44 @@ const agyAdapter = {
305
390
  createRenderer: () => createJsonlRenderer(renderAgyEvent),
306
391
  };
307
392
 
308
- const adapters = { claude: claudeAdapter, codex: codexAdapter, agy: agyAdapter };
393
+ /** @type {Adapter} */
394
+ const geminiAdapter = {
395
+ name: 'gemini',
396
+ version: cliVersion('gemini'),
397
+ // Gemini CLI exposes no reasoning-effort flag, so this adapter declares no
398
+ // `efforts`: a configured effort is simply not passed through (see docs).
399
+ command({ prompt, addDirs, permissions, artifactOnly = false, agent = /** @type {import('./types.js').Agent} */ ({}) }) {
400
+ // `yolo`, not `plan`/`auto_edit`: in headless `--prompt` mode Gemini cannot
401
+ // interactively approve the shell tools a worktree-writing phase needs to
402
+ // build, test, and commit. `plan` is read-only and `auto_edit` auto-approves
403
+ // only edit tools, so either one auto-denies the first shell call and the
404
+ // phase "completes" instantly with an empty log. `yolo` auto-approves every
405
+ // tool — Gemini's equivalent of the claude adapter's `--permission-mode
406
+ // auto` and agy's `--dangerously-skip-permissions`.
407
+ //
408
+ // `--skip-trust`: a fresh worktree is an untrusted folder, and Gemini
409
+ // refuses to run headlessly in one (it prints a trust warning and does
410
+ // nothing). This trusts the workspace for this one invocation only.
411
+ //
412
+ // stream-json, not the default `text`: text output withholds every byte
413
+ // until the process exits, so a multi-minute phase is indistinguishable from
414
+ // a hang. stream-json emits an event per step, which `createRenderer` turns
415
+ // back into readable lines — same reasoning as the claude adapter above.
416
+ const canWrite = permissionLevel(permissions) === PERMISSIONS.WRITE_WORKTREE || artifactOnly;
417
+ const args = [
418
+ '--prompt', prompt,
419
+ '--approval-mode', canWrite ? 'yolo' : 'plan',
420
+ '--skip-trust',
421
+ '--output-format', 'stream-json',
422
+ ];
423
+ if (agent.model) args.push('--model', agent.model);
424
+ for (const dir of addDirs) args.push('--include-directories', dir);
425
+ return { command: 'gemini', args };
426
+ },
427
+ createRenderer: () => createJsonlRenderer(renderGeminiEvent),
428
+ };
429
+
430
+ const adapters = { claude: claudeAdapter, codex: codexAdapter, agy: agyAdapter, gemini: geminiAdapter };
309
431
 
310
432
  function validateConfiguredAdapter(name, adapter) {
311
433
  if (!adapter || typeof adapter !== 'object' || Array.isArray(adapter) || typeof adapter.command !== 'function') {
@@ -323,6 +445,7 @@ function validateConfiguredAdapter(name, adapter) {
323
445
  return adapter;
324
446
  }
325
447
 
448
+ /** @returns {Adapter} */
326
449
  export function adapterFor(engine, customAdapters = {}) {
327
450
  const configuredNames = customAdapters && typeof customAdapters === 'object' ? Object.keys(customAdapters) : [];
328
451
  const hasConfiguredAdapter = customAdapters && typeof customAdapters === 'object'
@@ -335,6 +458,7 @@ export function adapterFor(engine, customAdapters = {}) {
335
458
  return adapter;
336
459
  }
337
460
 
461
+ /** @param {Engine} agent @returns {Engine} */
338
462
  export function validateAgent(agent, customAdapters = {}) {
339
463
  const adapter = adapterFor(agent.name, customAdapters);
340
464
  if (agent.effort && adapter.efforts && !adapter.efforts.includes(agent.effort)) {
@@ -344,11 +468,13 @@ export function validateAgent(agent, customAdapters = {}) {
344
468
  }
345
469
 
346
470
  /** Resolve an agent descriptor: per-phase override, else the default. */
471
+ /** @param {Config} config @returns {Engine} */
347
472
  export function agentForPhase(config, phaseName) {
348
473
  return config.engines[phaseName] ?? config.engines.default;
349
474
  }
350
475
 
351
476
  /** Resolve which executable runs a phase. Retained for string-based callers. */
477
+ /** @param {Config} config */
352
478
  export function engineForPhase(config, phaseName) {
353
479
  return agentForPhase(config, phaseName).name;
354
480
  }
@@ -83,7 +83,8 @@ function errorText(error) {
83
83
  * The default GitLab transport. `glab` is intentionally an external runtime
84
84
  * requirement, so consumers can replace this transport with REST or MCP code.
85
85
  */
86
- export function glabTransport({ cwd, runner = runCommand, env } = {}) {
86
+ export function glabTransport(options = {}) {
87
+ const { cwd, runner = runCommand, env } = /** @type {any} */ (options);
87
88
  async function run(args) {
88
89
  try {
89
90
  return await runner('glab', args, { cwd, ...(env ? { env } : {}) });
@@ -153,9 +154,10 @@ export function glabTransport({ cwd, runner = runCommand, env } = {}) {
153
154
  /**
154
155
  * Adapt GitLab-native merge request data to aloop's verified pull-request port.
155
156
  *
156
- * @param {{ cwd: string, git?: GitFacade, transport?: GitLabTransport, env?: object }} options
157
+ * @param {any} options
157
158
  */
158
- export function gitlabBackend({ cwd, git = new GitFacade(cwd), transport, env } = {}) {
159
+ export function gitlabBackend(options = {}) {
160
+ let { cwd, git = new GitFacade(cwd), transport, env } = options;
159
161
  transport ??= glabTransport({ cwd, env });
160
162
  const repositories = new Map();
161
163
 
package/src/command.mjs CHANGED
@@ -2,11 +2,8 @@ import { spawn } from 'node:child_process';
2
2
  import { existsSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import { win32 as windowsPath } from 'node:path';
4
4
 
5
- export function signalProcessGroup(pid, signal, {
6
- platform = process.platform,
7
- spawnImpl = spawn,
8
- onFailure,
9
- } = {}) {
5
+ export function signalProcessGroup(pid, signal, options = {}) {
6
+ const { platform = process.platform, spawnImpl = spawn, onFailure } = /** @type {any} */ (options);
10
7
  if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return false;
11
8
  if (platform === 'win32') {
12
9
  const handleFailure = onFailure ?? (() => {
@@ -63,10 +60,8 @@ function environmentValue(env, name) {
63
60
  return key ? env[key] : undefined;
64
61
  }
65
62
 
66
- export function resolveWindowsExecutable(command, env = process.env, {
67
- fileExists = existsSync,
68
- realPath = realpathSync.native,
69
- } = {}) {
63
+ export function resolveWindowsExecutable(command, env = process.env, options = {}) {
64
+ const { fileExists = existsSync, realPath = realpathSync.native } = /** @type {any} */ (options);
70
65
  const pathEntries = (environmentValue(env, 'PATH') ?? '').split(';').filter(Boolean);
71
66
  const pathExtensions = (environmentValue(env, 'PATHEXT') ?? WINDOWS_EXECUTABLE_EXTENSIONS.join(';'))
72
67
  .split(';')
@@ -144,7 +139,7 @@ export async function runCommand(command, args = [], options = {}) {
144
139
  platform = process.platform,
145
140
  spawnImpl = spawn,
146
141
  resolveExecutable = resolveWindowsExecutable,
147
- } = options;
142
+ } = /** @type {any} */ (options);
148
143
  return new Promise((resolve, reject) => {
149
144
  const spawnSpec = platform === 'win32' ? windowsSpawnSpec(command, args, env, resolveExecutable) : { command, args };
150
145
  const child = spawnImpl(spawnSpec.command, spawnSpec.args, {