@syntax-syllogism/aloop 0.7.0 → 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,7 +5,7 @@ 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]
8
+ ## [0.7.0] - 2026-09-18
9
9
 
10
10
  ### Added
11
11
 
@@ -17,12 +17,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
17
  Gemini has no reasoning-effort flag, so a configured `effort` is ignored for
18
18
  it.
19
19
 
20
- ## [0.7.0] - 2026-09-18
21
-
22
- ### Added
23
-
24
- - Gemini is now available as a built-in engine
25
-
26
20
  ## [0.6.0] - 2026-09-14
27
21
 
28
22
  ### Added
@@ -201,6 +195,26 @@ History from before the standalone extraction (changesets format), preserved for
201
195
  - Prompts ship with the package and are overridable per phase from
202
196
  `.loop/prompts/` in the consuming project.
203
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
+
204
218
  ## [0.6.2] - 2026-09-16
205
219
 
206
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 {
@@ -125,12 +132,13 @@ placeholder is unresolved.
125
132
  For repositories using Markdown work items, install the bundled preset:
126
133
 
127
134
  ```sh
128
- mkdir -p .loop/prompts
129
- cp -r node_modules/@syntax-syllogism/aloop/presets/work-item/prompts/. .loop/prompts/
135
+ aloop init --preset work-item
130
136
  ```
131
137
 
132
- The preset includes prompts and a sample configuration. Adapt it to the
133
- 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.
134
142
 
135
143
  ## Building up to unattended runs
136
144
 
@@ -141,6 +149,13 @@ Use this progression:
141
149
  3. Exercise the review loop with `--max-rounds 2`.
142
150
  4. Add `-y` only for task shapes whose behavior is already understood.
143
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
+
144
159
  ## Development
145
160
 
146
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,6 +121,10 @@ 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',
@@ -122,7 +138,10 @@ export function usage() {
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.7.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",
@@ -48,6 +49,11 @@
48
49
  "access": "public"
49
50
  },
50
51
  "scripts": {
51
- "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"
52
58
  }
53
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
  *
@@ -305,15 +309,17 @@ export function createJsonlRenderer(renderEvent) {
305
309
  }
306
310
 
307
311
  /** A renderer for engines whose stdout is already meant to be read. */
312
+ /** @returns {import('./types.js').Renderer} */
308
313
  export function passthroughRenderer() {
309
314
  return { write: (text) => text, end: () => '' };
310
315
  }
311
316
 
317
+ /** @type {Adapter} */
312
318
  const claudeAdapter = {
313
319
  name: 'claude',
314
320
  version: cliVersion('claude'),
315
321
  efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
316
- command({ prompt, addDirs, permissions, artifactOnly = false, agent = {} }) {
322
+ command({ prompt, addDirs, permissions, artifactOnly = false, agent = /** @type {import('./types.js').Agent} */ ({}) }) {
317
323
  // `auto`, not `acceptEdits`, is used for worktree-writing and
318
324
  // artifact-only phases: under acceptEdits a non-interactive `-p` run
319
325
  // auto-denies every Bash call, because there is no one to answer the
@@ -334,11 +340,12 @@ const claudeAdapter = {
334
340
  createRenderer: () => createJsonlRenderer(renderClaudeEvent),
335
341
  };
336
342
 
343
+ /** @type {Adapter} */
337
344
  const codexAdapter = {
338
345
  name: 'codex',
339
346
  version: cliVersion('codex'),
340
347
  efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
341
- command({ prompt, cwd, addDirs, permissions, artifactOnly = false, agent = {} }) {
348
+ command({ prompt, cwd, addDirs, permissions, artifactOnly = false, agent = /** @type {import('./types.js').Agent} */ ({}) }) {
342
349
  const canWrite = permissionLevel(permissions) === PERMISSIONS.WRITE_WORKTREE || artifactOnly;
343
350
  const sandbox = canWrite ? 'workspace-write' : 'read-only';
344
351
  const args = ['exec', prompt, '--sandbox', sandbox, '--cd', cwd];
@@ -349,11 +356,12 @@ const codexAdapter = {
349
356
  },
350
357
  };
351
358
 
359
+ /** @type {Adapter} */
352
360
  const agyAdapter = {
353
361
  name: 'agy',
354
362
  version: cliVersion('agy'),
355
363
  efforts: ['low', 'medium', 'high'],
356
- command({ prompt, addDirs, timeoutMs, permissions, artifactOnly = false, agent = {} }) {
364
+ command({ prompt, addDirs, timeoutMs, permissions, artifactOnly = false, agent = /** @type {import('./types.js').Agent} */ ({}) }) {
357
365
  // `--dangerously-skip-permissions`, not bare `accept-edits`: in headless
358
366
  // `--print` mode agy cannot prompt for the `command` permission its Bash-
359
367
  // style tools need, so it auto-denies the first one and exits 0 having done
@@ -382,12 +390,13 @@ const agyAdapter = {
382
390
  createRenderer: () => createJsonlRenderer(renderAgyEvent),
383
391
  };
384
392
 
393
+ /** @type {Adapter} */
385
394
  const geminiAdapter = {
386
395
  name: 'gemini',
387
396
  version: cliVersion('gemini'),
388
397
  // Gemini CLI exposes no reasoning-effort flag, so this adapter declares no
389
398
  // `efforts`: a configured effort is simply not passed through (see docs).
390
- command({ prompt, addDirs, permissions, artifactOnly = false, agent = {} }) {
399
+ command({ prompt, addDirs, permissions, artifactOnly = false, agent = /** @type {import('./types.js').Agent} */ ({}) }) {
391
400
  // `yolo`, not `plan`/`auto_edit`: in headless `--prompt` mode Gemini cannot
392
401
  // interactively approve the shell tools a worktree-writing phase needs to
393
402
  // build, test, and commit. `plan` is read-only and `auto_edit` auto-approves
@@ -436,6 +445,7 @@ function validateConfiguredAdapter(name, adapter) {
436
445
  return adapter;
437
446
  }
438
447
 
448
+ /** @returns {Adapter} */
439
449
  export function adapterFor(engine, customAdapters = {}) {
440
450
  const configuredNames = customAdapters && typeof customAdapters === 'object' ? Object.keys(customAdapters) : [];
441
451
  const hasConfiguredAdapter = customAdapters && typeof customAdapters === 'object'
@@ -448,6 +458,7 @@ export function adapterFor(engine, customAdapters = {}) {
448
458
  return adapter;
449
459
  }
450
460
 
461
+ /** @param {Engine} agent @returns {Engine} */
451
462
  export function validateAgent(agent, customAdapters = {}) {
452
463
  const adapter = adapterFor(agent.name, customAdapters);
453
464
  if (agent.effort && adapter.efforts && !adapter.efforts.includes(agent.effort)) {
@@ -457,11 +468,13 @@ export function validateAgent(agent, customAdapters = {}) {
457
468
  }
458
469
 
459
470
  /** Resolve an agent descriptor: per-phase override, else the default. */
471
+ /** @param {Config} config @returns {Engine} */
460
472
  export function agentForPhase(config, phaseName) {
461
473
  return config.engines[phaseName] ?? config.engines.default;
462
474
  }
463
475
 
464
476
  /** Resolve which executable runs a phase. Retained for string-based callers. */
477
+ /** @param {Config} config */
465
478
  export function engineForPhase(config, phaseName) {
466
479
  return agentForPhase(config, phaseName).name;
467
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, {
package/src/config.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ /** @typedef {import('./types.js').Config} Config */
2
+ /** @typedef {import('./types.js').PhaseDescriptor} PhaseDescriptor */
3
+
1
4
  import { access } from 'node:fs/promises';
2
5
  import { constants } from 'node:fs';
3
6
  import { join, resolve } from 'node:path';
@@ -184,6 +187,7 @@ function toPhase(entry, index) {
184
187
  * A custom verdict phase must declare its own `repair` list; merely placing a
185
188
  * repair phase after it no longer changes control flow.
186
189
  */
190
+ /** @returns {PhaseDescriptor[]} */
187
191
  export function normalizePhases(entries, { maxRounds }) {
188
192
  const list = entries.map(toPhase);
189
193
  const phaseIndexes = new Map();
@@ -215,7 +219,7 @@ export function normalizePhases(entries, { maxRounds }) {
215
219
 
216
220
  for (let index = 0; index < list.length; index += 1) {
217
221
  const phase = list[index];
218
- if (!phase.verdict) continue;
222
+ if (!phase.verdict && !(phase.kind === 'gate' && phase.repair)) continue;
219
223
  const repair = phase.repair ?? [];
220
224
  if (!Array.isArray(repair)) {
221
225
  throw new Error(`Phase "${phase.name}" has an invalid repair transition; expected an array.`);
@@ -233,7 +237,7 @@ export function normalizePhases(entries, { maxRounds }) {
233
237
  for (let index = 0; index < list.length; index += 1) {
234
238
  const phase = list[index];
235
239
  if (phase.role === 'repair' && !consumedRepairs.has(index)) {
236
- throw new Error(`Phase "${phase.name}" is a repair phase and must be declared by a phase that emits a verdict.`);
240
+ throw new Error(`Phase "${phase.name}" is a repair phase and must be declared by a phase that emits a verdict or gate repair transition.`);
237
241
  }
238
242
  if (!consumedRepairs.has(index)) result.push(phase);
239
243
  }
@@ -356,6 +360,7 @@ export async function loadRunsDir(cwd, configPath) {
356
360
  return runsDir;
357
361
  }
358
362
 
363
+ /** @returns {Promise<Config>} */
359
364
  export async function loadConfig(cwd, overrides = {}, configPath) {
360
365
  const configured = await importConfigFile(cwd, configPath);
361
366
  const merged = {