@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 +32 -0
- package/README.md +25 -9
- package/bin/eval.mjs +58 -0
- package/bin/loop.mjs +32 -3
- package/package.json +10 -3
- package/presets/work-item/README.md +9 -7
- package/prompts/fix-gate.md +17 -0
- package/src/adapters.mjs +131 -5
- package/src/backends/gitlab.mjs +5 -3
- package/src/command.mjs +5 -10
- package/src/config.mjs +7 -2
- package/src/eval.mjs +217 -0
- package/src/index.mjs +7 -0
- package/src/manifest.mjs +4 -0
- package/src/operations.mjs +120 -7
- package/src/pipeline.mjs +62 -19
- package/src/publish.mjs +4 -12
- package/src/reporter.mjs +37 -1
- package/src/runner.mjs +153 -21
- package/src/state.mjs +5 -1
- package/src/tui.mjs +171 -0
- package/src/types.d.ts +238 -0
- package/src/verdict.mjs +9 -1
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
|
|
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
|
-
|
|
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 `
|
|
89
|
-
CLI must already be installed and authenticated. Engine
|
|
90
|
-
an engine-specific `model` and `effort
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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.
|
|
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,
|
|
22
|
-
the project override directory:
|
|
21
|
+
Install the package, then initialize the project with the preset:
|
|
23
22
|
|
|
24
23
|
```sh
|
|
25
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/backends/gitlab.mjs
CHANGED
|
@@ -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(
|
|
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 {
|
|
157
|
+
* @param {any} options
|
|
157
158
|
*/
|
|
158
|
-
export function gitlabBackend(
|
|
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, {
|