@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/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 = {
package/src/eval.mjs ADDED
@@ -0,0 +1,217 @@
1
+ /** @typedef {import('./types.js').Summary} Summary */
2
+
3
+ import { execFile } from 'node:child_process';
4
+ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join } from 'node:path';
7
+ import { promisify } from 'node:util';
8
+ import { computeRunMetrics, readRunManifest } from './metrics.mjs';
9
+ import { runLoop } from './pipeline.mjs';
10
+
11
+ const exec = promisify(execFile);
12
+
13
+ /**
14
+ * A task corpus entry: a self-contained repo fixture plus a task description
15
+ * and (optionally) a held-out check that never appears in the visible gate.
16
+ * Escaped defects are the gap between what the gate/review approved and what
17
+ * the held-out check demands.
18
+ *
19
+ * @typedef {Object} EvalTask
20
+ * @property {string} id
21
+ * @property {Record<string, string>} [files] - initial repo contents, relative to the fixture root
22
+ * @property {string} [task] - inline task text written to the run's task file
23
+ * @property {string[]} [gate] - visible gate commands (what the loop enforces)
24
+ * @property {string[]} [heldOutCheck] - hidden verification commands run after approval
25
+ * @property {string[]} [phases] - phase list override (default: implement, gate, review)
26
+ */
27
+
28
+ /**
29
+ * A matrix cell: a model/engine/config to run every task under. `configSource`
30
+ * gives full control over the generated `loop.config.mjs` (needed for custom
31
+ * adapters, e.g. a fake engine in tests); when omitted, a minimal config is
32
+ * generated from `engine`, `phases`, and `configExtra`.
33
+ *
34
+ * @typedef {Object} EvalConfig
35
+ * @property {string} name
36
+ * @property {string} [engine]
37
+ * @property {string[]} [phases]
38
+ * @property {Record<string, unknown>} [configExtra]
39
+ * @property {((task: EvalTask) => string)|string} [configSource]
40
+ * @property {Record<string, unknown>} [runArgs]
41
+ */
42
+
43
+ async function git(cwd, ...args) {
44
+ return (await exec('git', args, { cwd })).stdout.trim();
45
+ }
46
+
47
+ async function writeTaskFiles(root, files) {
48
+ for (const [relPath, contents] of Object.entries(files ?? {})) {
49
+ const full = join(root, relPath);
50
+ await mkdir(dirname(full), { recursive: true });
51
+ await writeFile(full, contents);
52
+ }
53
+ }
54
+
55
+ async function runShellChecks(cwd, commands) {
56
+ for (const command of commands ?? []) {
57
+ try {
58
+ await exec('sh', ['-c', command], { cwd });
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+ return true;
64
+ }
65
+
66
+ function defaultConfigSource(task, config) {
67
+ const body = {
68
+ gate: task.gate ?? [],
69
+ worktrees: false,
70
+ phases: config.phases ?? task.phases ?? ['implement', 'gate', 'review'],
71
+ engines: { default: config.engine ?? 'claude' },
72
+ ...(config.configExtra ?? {}),
73
+ };
74
+ return `export default ${JSON.stringify(body, null, 2)};\n`;
75
+ }
76
+
77
+ function resolveConfigSource(task, config) {
78
+ if (typeof config.configSource === 'function') return config.configSource(task);
79
+ if (typeof config.configSource === 'string') return config.configSource;
80
+ return defaultConfigSource(task, config);
81
+ }
82
+
83
+ /**
84
+ * Run one task through the loop under one config, in a throwaway repo, and
85
+ * report completion, escaped defects (via the held-out check), convergence,
86
+ * cost, and time — reading cost/convergence from the run's own manifest so the
87
+ * numbers are exactly what {@link computeRunMetrics} would report for a single
88
+ * real run.
89
+ *
90
+ * @param {{ task: EvalTask, config: EvalConfig, baseDir?: string }} options
91
+ */
92
+ export async function runEvalCell({ task, config, baseDir }) {
93
+ const root = await mkdtemp(join(baseDir ?? tmpdir(), 'aloop-eval-'));
94
+ await git(root, 'init', '--initial-branch=master');
95
+ await git(root, 'config', 'user.email', 'eval@example.com');
96
+ await git(root, 'config', 'user.name', 'Eval Harness');
97
+ await writeTaskFiles(root, task.files);
98
+ // The loop writes its run state (manifest, logs, lock files) under .loop/
99
+ // inside the repo it's driving; without ignoring it, that bookkeeping shows
100
+ // up as untracked changes and trips the implement/review clean-tree check.
101
+ if (!task.files?.['.gitignore']) await writeFile(join(root, '.gitignore'), '.loop/\n');
102
+ await git(root, 'add', '.');
103
+ await git(root, 'commit', '-m', 'chore: init eval fixture');
104
+
105
+ await writeFile(join(root, 'loop.config.mjs'), resolveConfigSource(task, config));
106
+ const taskFile = join(root, 'TASK.md');
107
+ await writeFile(taskFile, task.task ?? '');
108
+
109
+ let summary = null;
110
+ let error = null;
111
+ try {
112
+ summary = await runLoop({ args: { taskFile, cwd: root, name: task.id, yes: true, ...(config.runArgs ?? {}) } });
113
+ } catch (caught) {
114
+ error = caught;
115
+ }
116
+
117
+ const completed = Boolean(summary && summary.stalled === null);
118
+ const manifest = summary ? await readRunManifest(summary.runDir) : null;
119
+ const metrics = manifest ? computeRunMetrics(manifest) : null;
120
+
121
+ let escapedDefect = null;
122
+ if (completed && task.heldOutCheck?.length) {
123
+ escapedDefect = !(await runShellChecks(summary.worktree, task.heldOutCheck));
124
+ }
125
+
126
+ const approvedSha = completed ? await git(summary.worktree, 'rev-parse', 'HEAD').catch(() => null) : null;
127
+
128
+ return {
129
+ task: task.id,
130
+ config: config.name,
131
+ completed,
132
+ stalled: summary?.stalled ?? null,
133
+ escapedDefect,
134
+ roundsToConverge: metrics?.convergence.roundsToConverge ?? null,
135
+ cost: metrics?.total.cost ?? null,
136
+ durationMs: metrics?.total.durationMs ?? null,
137
+ approvedSha,
138
+ runDir: summary?.runDir ?? null,
139
+ snapshotPath: summary ? join(summary.runDir, 'snapshot.json') : null,
140
+ error: error ? error.message : null,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Run every task × config cell in the matrix.
146
+ *
147
+ * @param {{ corpus: EvalTask[], matrix: EvalConfig[], baseDir?: string }} options
148
+ */
149
+ export async function runEval({ corpus, matrix, baseDir }) {
150
+ const results = [];
151
+ for (const task of corpus) {
152
+ for (const config of matrix) {
153
+ results.push(await runEvalCell({ task, config, baseDir }));
154
+ }
155
+ }
156
+ return results;
157
+ }
158
+
159
+ /** Aggregate per-config totals across every task cell. */
160
+ export function summarizeEval(results) {
161
+ const byConfig = new Map();
162
+ for (const cell of results) {
163
+ if (!byConfig.has(cell.config)) byConfig.set(cell.config, []);
164
+ byConfig.get(cell.config).push(cell);
165
+ }
166
+ return [...byConfig.entries()].map(([config, cells]) => {
167
+ const escapedChecked = cells.filter((cell) => cell.escapedDefect !== null);
168
+ const escaped = escapedChecked.filter((cell) => cell.escapedDefect).length;
169
+ const converged = cells.filter((cell) => cell.roundsToConverge !== null);
170
+ const costKnown = cells.length > 0 && cells.every((cell) => cell.cost !== null);
171
+ return {
172
+ config,
173
+ cells: cells.length,
174
+ completionRate: cells.length ? cells.filter((cell) => cell.completed).length / cells.length : null,
175
+ escapedDefectRate: escapedChecked.length ? escaped / escapedChecked.length : null,
176
+ avgRoundsToConverge: converged.length
177
+ ? converged.reduce((total, cell) => total + cell.roundsToConverge, 0) / converged.length
178
+ : null,
179
+ totalCost: costKnown ? cells.reduce((total, cell) => total + cell.cost, 0) : null,
180
+ totalDurationMs: cells.reduce((total, cell) => total + (cell.durationMs ?? 0), 0),
181
+ };
182
+ });
183
+ }
184
+
185
+ function fmt(value) {
186
+ return value === null || value === undefined ? 'unknown' : String(value);
187
+ }
188
+
189
+ /** Render per-cell results as a tab-separated table. */
190
+ export function formatEvalTable(results) {
191
+ const header = ['TASK', 'CONFIG', 'COMPLETED', 'ESCAPED_DEFECT', 'ROUNDS', 'COST', 'DURATION_MS'];
192
+ const rows = results.map((cell) => [
193
+ cell.task,
194
+ cell.config,
195
+ String(cell.completed),
196
+ cell.escapedDefect === null ? 'n/a' : String(cell.escapedDefect),
197
+ fmt(cell.roundsToConverge),
198
+ fmt(cell.cost),
199
+ fmt(cell.durationMs),
200
+ ]);
201
+ return [header, ...rows].map((row) => row.join('\t')).join('\n');
202
+ }
203
+
204
+ /** Render per-config aggregate summaries as a tab-separated table. */
205
+ export function formatEvalSummaryTable(summaries) {
206
+ const header = ['CONFIG', 'CELLS', 'COMPLETION_RATE', 'ESCAPED_DEFECT_RATE', 'AVG_ROUNDS', 'TOTAL_COST', 'TOTAL_DURATION_MS'];
207
+ const rows = summaries.map((summary) => [
208
+ summary.config,
209
+ String(summary.cells),
210
+ fmt(summary.completionRate),
211
+ fmt(summary.escapedDefectRate),
212
+ fmt(summary.avgRoundsToConverge),
213
+ fmt(summary.totalCost),
214
+ String(summary.totalDurationMs),
215
+ ]);
216
+ return [header, ...rows].map((row) => row.join('\t')).join('\n');
217
+ }
package/src/index.mjs CHANGED
@@ -23,5 +23,12 @@ export {
23
23
  cleanRuns,
24
24
  doctor,
25
25
  } from './operations.mjs';
26
+ export {
27
+ runEval,
28
+ runEvalCell,
29
+ summarizeEval,
30
+ formatEvalTable,
31
+ formatEvalSummaryTable,
32
+ } from './eval.mjs';
26
33
  export { githubBackend } from './publish.mjs';
27
34
  export { gitlabBackend, glabTransport, parseGitLabRemoteUrl } from './backends/gitlab.mjs';
package/src/manifest.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ /** @typedef {import('./types.js').ManifestEntry} ManifestEntry */
2
+
1
3
  import { createHash } from 'node:crypto';
2
4
  import { rename, writeFile } from 'node:fs/promises';
3
5
  import { join } from 'node:path';
@@ -40,10 +42,12 @@ export function hashConfig(config) {
40
42
  }
41
43
 
42
44
  export class Manifest {
45
+ /** @param {ManifestEntry[]} [entries] */
43
46
  constructor(entries = []) {
44
47
  this.entries = [...entries];
45
48
  }
46
49
 
50
+ /** @param {ManifestEntry} entry @returns {ManifestEntry} */
47
51
  append(entry) {
48
52
  const now = new Date().toISOString();
49
53
  const recorded = {
@@ -1,6 +1,6 @@
1
- import { access, readFile, readdir, rm, stat } from 'node:fs/promises';
1
+ import { access, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
2
2
  import { constants } from 'node:fs';
3
- import { join, resolve } from 'node:path';
3
+ import { dirname, join, relative, resolve } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
5
  import { adapterFor, agentForPhase } from './adapters.mjs';
6
6
  import { runCommand, signalProcessGroup } from './command.mjs';
@@ -8,9 +8,44 @@ import { defaults, loadConfig, loadRunsDir } from './config.mjs';
8
8
  import { GitFacade } from './git.mjs';
9
9
  import { computeAggregateMetrics, computeRunMetrics, readRunManifests } from './metrics.mjs';
10
10
  import { RunState, slugFor } from './state.mjs';
11
+ import { packagePrompts } from './prompts.mjs';
11
12
 
12
13
  const PULL_REQUEST_URL = /https?:\/\/[^\s"'`<>]+(?:\/pull\/\d+|\/merge_requests\/\d+)[^\s"'`<>]*/i;
13
14
 
15
+ const packagePresets = join(dirname(packagePrompts), 'presets');
16
+
17
+ const CONFIG_HEADER = `// aloop configuration — see docs/loop.md for the full reference.
18
+ //
19
+ // REVIEW THESE before your first run:
20
+ // baseBranch the branch you merge into (for example, 'main' or 'master')
21
+ // remote set this when your repository has more than one remote
22
+ // engines choose the agent that runs each phase
23
+ //
24
+ // SAFE TO CHANGE anytime:
25
+ // phases the pipeline stages and their order
26
+ // gate commands that must pass before review
27
+ // branchPrefix, maxRounds, timeoutMs
28
+ //
29
+ // Prompts live in .loop/prompts/ — edit any *.md there to change what each
30
+ // phase tells the agent. Delete a file to fall back to the packaged default.
31
+ `;
32
+
33
+ const CONFIG_FOOTER = '// happy agentic looping :)\n';
34
+
35
+ const EMBEDDED_STARTER = `export default {
36
+ baseBranch: 'master', // <- set to your default branch
37
+ branchPrefix: 'feat/',
38
+ remote: null, // <- set this if you have more than one remote
39
+ engines: {
40
+ default: { name: 'claude' },
41
+ // review: { name: 'codex' }, // optional: use a different reviewer
42
+ },
43
+ phases: ['implement', 'docs', 'gate', 'review', 'address', 'pr-description', 'publish'],
44
+ gate: [], // <- e.g. ['npm test'] before review
45
+ maxRounds: 3,
46
+ };
47
+ `;
48
+
14
49
  async function readJson(path, { optional = true } = {}) {
15
50
  try {
16
51
  return JSON.parse(await readFile(path, 'utf8'));
@@ -417,7 +452,8 @@ async function operationalContext(cwd, configPath, { validate = false } = {}) {
417
452
  return { config, repoRoot, rootGit, runsDir: resolve(repoRoot, config.runsDir) };
418
453
  }
419
454
 
420
- export async function listRuns({ cwd = process.cwd(), configPath } = {}) {
455
+ export async function listRuns(options = {}) {
456
+ const { cwd = process.cwd(), configPath } = /** @type {any} */ (options);
421
457
  const context = await operationalContext(cwd, configPath);
422
458
  let entries;
423
459
  try {
@@ -434,7 +470,8 @@ export async function listRuns({ cwd = process.cwd(), configPath } = {}) {
434
470
  return runs.sort((a, b) => (b.updatedAt ?? b.startedAt ?? '').localeCompare(a.updatedAt ?? a.startedAt ?? ''));
435
471
  }
436
472
 
437
- export async function getRun(name, { cwd = process.cwd(), configPath } = {}) {
473
+ export async function getRun(name, options = {}) {
474
+ const { cwd = process.cwd(), configPath } = /** @type {any} */ (options);
438
475
  const slug = slugFor(name);
439
476
  if (!slug) throw new Error('A run name is required.');
440
477
  const context = await operationalContext(cwd, configPath);
@@ -453,7 +490,8 @@ export async function inspectRun(name, options = {}) {
453
490
  };
454
491
  }
455
492
 
456
- export async function cancelRun(name, { cwd = process.cwd(), configPath } = {}) {
493
+ export async function cancelRun(name, options = {}) {
494
+ const { cwd = process.cwd(), configPath } = /** @type {any} */ (options);
457
495
  const context = await operationalContext(cwd, configPath);
458
496
  const slug = slugFor(name);
459
497
  if (!slug) throw new Error('A run name is required.');
@@ -570,7 +608,8 @@ async function stopProcesses(targets) {
570
608
  return waitForProcessesToStop(watched, 5000);
571
609
  }
572
610
 
573
- export async function cleanRuns({ cwd = process.cwd(), configPath, olderThanDays = 30, dryRun = false, yes = false } = {}) {
611
+ export async function cleanRuns(options = {}) {
612
+ const { cwd = process.cwd(), configPath, olderThanDays = 30, dryRun = false, yes = false } = /** @type {any} */ (options);
574
613
  const context = await operationalContext(cwd, configPath);
575
614
  const runs = await listRuns({ cwd, configPath });
576
615
  const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
@@ -603,6 +642,79 @@ export async function cleanRuns({ cwd = process.cwd(), configPath, olderThanDays
603
642
  };
604
643
  }
605
644
 
645
+ async function promptFilesIn(directory) {
646
+ const entries = await readdir(directory, { withFileTypes: true });
647
+ return entries
648
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
649
+ .map((entry) => entry.name)
650
+ .sort();
651
+ }
652
+
653
+ async function bundledPresets() {
654
+ const entries = await readdir(packagePresets, { withFileTypes: true });
655
+ const presets = new Map();
656
+ for (const entry of entries) {
657
+ if (!entry.isDirectory()) continue;
658
+ const root = join(packagePresets, entry.name);
659
+ if (await pathExists(join(root, 'loop.config.mjs')) && await isDirectory(join(root, 'prompts'))) {
660
+ presets.set(entry.name, root);
661
+ }
662
+ }
663
+ return presets;
664
+ }
665
+
666
+ async function resolvePresetRoot(name) {
667
+ if (typeof name !== 'string' || !name.trim()) throw new Error('--preset requires a name.');
668
+ const presets = await bundledPresets();
669
+ const root = presets.get(name);
670
+ if (root) return root;
671
+ const available = [...presets.keys()].sort().join(', ') || '(none)';
672
+ throw new Error(`Unknown preset "${name}". Available: ${available}.`);
673
+ }
674
+
675
+ async function renderInitConfig(presetRoot) {
676
+ const body = presetRoot
677
+ ? await readFile(join(presetRoot, 'loop.config.mjs'), 'utf8')
678
+ : EMBEDDED_STARTER;
679
+ return `${CONFIG_HEADER}\n${body.trimEnd()}\n\n${CONFIG_FOOTER}`;
680
+ }
681
+
682
+ /**
683
+ * Scaffold a self-contained aloop configuration and editable prompt overrides.
684
+ */
685
+ export async function init({ cwd = process.cwd(), preset = null, force = false } = {}) {
686
+ const presetRoot = preset === null || preset === undefined ? null : await resolvePresetRoot(preset);
687
+ const promptTargetDir = join(cwd, '.loop', 'prompts');
688
+ const promptSources = [packagePrompts, ...(presetRoot ? [join(presetRoot, 'prompts')] : [])];
689
+ const prompts = new Map();
690
+ for (const sourceDir of promptSources) {
691
+ for (const name of await promptFilesIn(sourceDir)) prompts.set(name, join(sourceDir, name));
692
+ }
693
+
694
+ const plan = /** @type {any[]} */ ([
695
+ { target: join(cwd, 'loop.config.mjs'), kind: 'config' },
696
+ ...[...prompts.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([name, source]) => ({
697
+ target: join(promptTargetDir, name), source, kind: 'prompt',
698
+ })),
699
+ ]);
700
+ const existing = [];
701
+ for (const item of plan) {
702
+ if (await pathExists(item.target)) existing.push(relative(cwd, item.target));
703
+ }
704
+ if (existing.length && !force) {
705
+ const error = new Error(`Refusing to overwrite existing files:\n ${existing.join('\n ')}\nRe-run with --force to overwrite.`);
706
+ error.command = true;
707
+ throw error;
708
+ }
709
+
710
+ await mkdir(promptTargetDir, { recursive: true });
711
+ for (const item of plan) {
712
+ if (item.kind === 'config') await writeFile(item.target, await renderInitConfig(presetRoot), 'utf8');
713
+ else await copyFile(item.source, item.target);
714
+ }
715
+ return { preset: presetRoot ? preset : null, created: plan.map((item) => relative(cwd, item.target)), skipped: [] };
716
+ }
717
+
606
718
  async function checkCommand(command, args, cwd) {
607
719
  try {
608
720
  await runCommand(command, args, { cwd });
@@ -612,7 +724,8 @@ async function checkCommand(command, args, cwd) {
612
724
  }
613
725
  }
614
726
 
615
- export async function doctor({ cwd = process.cwd(), configPath } = {}) {
727
+ export async function doctor(options = {}) {
728
+ const { cwd = process.cwd(), configPath } = /** @type {any} */ (options);
616
729
  const checks = [];
617
730
  let context;
618
731
  try {
package/src/pipeline.mjs CHANGED
@@ -1,4 +1,8 @@
1
- import { appendFile, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
1
+ /** @typedef {import('./types.js').Config} Config */
2
+ /** @typedef {import('./types.js').Operations} Operations */
3
+ /** @typedef {import('./types.js').Summary} Summary */
4
+
5
+ import { appendFile, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
2
6
  import { tmpdir } from 'node:os';
3
7
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
8
  import packageJson from '../package.json' with { type: 'json' };
@@ -11,9 +15,10 @@ import { canonicalizeConfig, hashConfig, hashText } from './manifest.mjs';
11
15
  import { computeRunMetrics } from './metrics.mjs';
12
16
  import { publish } from './publish.mjs';
13
17
  import { renderPrompt } from './prompts.mjs';
14
- import { banner, describeAgent, ensureConfirmationAvailable, log, openTerminalInput, report } from './reporter.mjs';
18
+ import { banner, describeAgent, ensureConfirmationAvailable, log, openTerminalInput, report, resetRenderer, setRenderer, writeOutput } from './reporter.mjs';
15
19
  import { RunState, slugFor } from './state.mjs';
16
20
  import { runPhases } from './runner.mjs';
21
+ import { createTuiRenderer, tuiEnabled } from './tui.mjs';
17
22
  import { formatFindings } from './verdict.mjs';
18
23
  import { planWorktree, resumedWorktree, setupWorktree } from './worktree.mjs';
19
24
 
@@ -61,6 +66,18 @@ function configOverrideRecord(config, path) {
61
66
  };
62
67
  }
63
68
 
69
+ async function resolveOperatorNote(args, cwd) {
70
+ const text = args.note ?? (args.noteFile ? await readFile(resolve(cwd, args.noteFile), 'utf8') : null);
71
+ if (text !== null && !text.trim()) {
72
+ throw new Error('--note and --note-file must contain non-whitespace text.');
73
+ }
74
+ return text === null ? null : { text };
75
+ }
76
+
77
+ function operatorNoteBlock(text) {
78
+ return `## Operator note (read this first)\n\nAn operator is resuming this phase and has provided the following instruction. Treat it as authoritative for what remains to be done. Do not redo or revert work that is already complete; do only what is needed to satisfy it and the phase's postconditions.\n\n${text}\n\n---\n\n`;
79
+ }
80
+
64
81
  async function fileExists(path) {
65
82
  try {
66
83
  return (await stat(path)).isFile();
@@ -133,6 +150,7 @@ function engineRecord(agent) {
133
150
  };
134
151
  }
135
152
 
153
+ /** @returns {import('./types.js').ManifestEntry} */
136
154
  function manifestEntry(phase, ctx, values = {}) {
137
155
  return {
138
156
  phase: phase.name,
@@ -273,7 +291,7 @@ async function runGate(phase, ctx) {
273
291
  timeoutMs: ctx.config.timeoutMs,
274
292
  activeProcessPath: ctx.activeProcessPath,
275
293
  onOutput: (text) => {
276
- process.stdout.write(text);
294
+ writeOutput(text);
277
295
  void tee(logFile, text);
278
296
  },
279
297
  });
@@ -344,7 +362,7 @@ async function runPublish(phase, ctx, { remote, branch, base, approvedSha }) {
344
362
  return { ok: Boolean(result), result, manifest, failure };
345
363
  }
346
364
 
347
- async function withRetries(phase, operation, { failed = () => false } = {}) {
365
+ async function withRetries(phase, operation, { failed = /** @type {(value: any) => boolean} */ (() => false) } = {}) {
348
366
  const maxAttempts = phase.retry?.maxAttempts ?? 1;
349
367
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
350
368
  try {
@@ -369,7 +387,7 @@ async function runSetup(commands, ctx) {
369
387
  timeoutMs: ctx.config.timeoutMs,
370
388
  activeProcessPath: ctx.activeProcessPath,
371
389
  onOutput: (text) => {
372
- process.stdout.write(text);
390
+ writeOutput(text);
373
391
  void tee(logFile, text);
374
392
  },
375
393
  });
@@ -450,12 +468,16 @@ async function runAgent(phase, ctx, variables) {
450
468
  const agentRepo = sourceSnapshot?.path ?? ctx.worktree;
451
469
 
452
470
  try {
453
- const { prompt, path } = await renderPrompt(phase.prompt, {
471
+ const rendered = await renderPrompt(phase.prompt, {
454
472
  ...variables,
455
473
  ...(sourceSnapshot ? { REPO: agentRepo } : {}),
456
474
  }, {
457
475
  projectPromptDir: ctx.promptDir,
458
476
  });
477
+ const receivesOperatorNote = ctx.note
478
+ && (ctx.note.targetPhase === phase.name || ctx.note.repairPhase === phase.name);
479
+ const prompt = receivesOperatorNote ? `${operatorNoteBlock(ctx.note.text)}${rendered.prompt}` : rendered.prompt;
480
+ const { path } = rendered;
459
481
  const promptHash = hashText(prompt);
460
482
  log(` engine: ${agent.name}${agent.model ? ` model: ${agent.model}` : ''}${agent.effort ? ` effort: ${agent.effort}` : ''} prompt: ${path}`);
461
483
  if (engineOverride) log(` resumed override: ${describeAgent(engineOverride.from)} → ${describeAgent(engineOverride.to)}`);
@@ -507,7 +529,7 @@ async function runAgent(phase, ctx, variables) {
507
529
  const emit = (text) => {
508
530
  if (!text) return;
509
531
  bytesEmitted += Buffer.byteLength(text);
510
- process.stdout.write(text);
532
+ writeOutput(text);
511
533
  pendingLogWrites.push(tee(logFile, text));
512
534
  };
513
535
  let commandError;
@@ -573,10 +595,12 @@ export async function runLoop(options = {}) {
573
595
  const cwd = args.cwd ?? process.cwd();
574
596
  const rootGit = new GitFacade(cwd);
575
597
  const repoRoot = await rootGit.toplevel();
598
+ const operatorNote = await resolveOperatorNote(args, cwd);
576
599
 
577
600
  if (args.config && args.overrideEngine) {
578
601
  throw new Error('--config and --override-engine cannot be used together; configure engines in the supplied config file.');
579
602
  }
603
+ /** @type {Config} */
580
604
  const config = await loadConfig(repoRoot, {
581
605
  ...(args.maxRounds ? { maxRounds: args.maxRounds } : {}),
582
606
  ...(args.phases ? { phases: args.phases } : {}),
@@ -616,6 +640,7 @@ export async function runLoop(options = {}) {
616
640
  );
617
641
 
618
642
  let runStarted = false;
643
+ let tuiRenderer = null;
619
644
  try {
620
645
  const persistedName = args.resume ? state.data.name : null;
621
646
  if (persistedName && args.name && slugFor(args.name) !== persistedName) {
@@ -787,6 +812,7 @@ export async function runLoop(options = {}) {
787
812
  engineOverrides,
788
813
  budgetWarnings: new Set(),
789
814
  configHash: hashConfig(config),
815
+ note: operatorNote,
790
816
  resume: Boolean(args.resume),
791
817
  dryRun: args.dryRun,
792
818
  remote,
@@ -835,6 +861,7 @@ export async function runLoop(options = {}) {
835
861
  log(`run dir : ${state.dir}`);
836
862
  log(`phases : ${config.resolvedPhases.map((phase) => phase.name).join(' → ')}`);
837
863
 
864
+ /** @type {Summary} */
838
865
  const summary = {
839
866
  phases: [],
840
867
  stalled: null,
@@ -849,6 +876,24 @@ export async function runLoop(options = {}) {
849
876
  pullRequest: state.data.pullRequest ?? state.manifestMetadata.pullRequest ?? null,
850
877
  };
851
878
 
879
+ if (!args.dryRun && tuiEnabled({ isTTY: process.stdout.isTTY, noTui: args.noTui })) {
880
+ tuiRenderer = createTuiRenderer({ config, state, summary });
881
+ setRenderer(tuiRenderer);
882
+ }
883
+
884
+ const operations = /** @satisfies {Operations} */ ({
885
+ budgetStatus,
886
+ markBudgetExhaustedComplete,
887
+ manifestEntry,
888
+ markStalledManifest,
889
+ recordBudgetStall,
890
+ recordManifest,
891
+ runAgent,
892
+ runGate,
893
+ runPublish,
894
+ withRetries,
895
+ });
896
+
852
897
  await runPhases({
853
898
  args,
854
899
  config,
@@ -861,24 +906,18 @@ export async function runLoop(options = {}) {
861
906
  baseBranch,
862
907
  confirmInput,
863
908
  terminalOpener,
864
- operations: {
865
- budgetStatus,
866
- markBudgetExhaustedComplete,
867
- manifestEntry,
868
- markStalledManifest,
869
- recordBudgetStall,
870
- recordManifest,
871
- runAgent,
872
- runGate,
873
- runPublish,
874
- withRetries,
875
- },
909
+ operations,
876
910
  });
877
911
 
878
912
  await state.record({
879
913
  status: summary.stalled ? 'stalled' : 'completed',
880
914
  ...(summary.stalled ? { stalled: summary.stalled } : {}),
881
915
  });
916
+ if (tuiRenderer) {
917
+ tuiRenderer.stop();
918
+ resetRenderer();
919
+ tuiRenderer = null;
920
+ }
882
921
  report(summary, formatFindings);
883
922
  return summary;
884
923
 
@@ -886,6 +925,10 @@ export async function runLoop(options = {}) {
886
925
  if (runStarted && !args.dryRun) await state.record({ status: 'stalled' }).catch(() => {});
887
926
  throw error;
888
927
  } finally {
928
+ if (tuiRenderer) {
929
+ tuiRenderer.stop();
930
+ resetRenderer();
931
+ }
889
932
  await state.release();
890
933
  }
891
934
  }