@syntax-syllogism/aloop 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/config.mjs CHANGED
@@ -2,7 +2,14 @@ import { access } from 'node:fs/promises';
2
2
  import { constants } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { pathToFileURL } from 'node:url';
5
- import { validateAgent } from './adapters.mjs';
5
+ import { PERMISSIONS, validateAgent } from './adapters.mjs';
6
+ import { normalizeHermeticConfig, resolvePhaseHermetic } from './hermetic.mjs';
7
+
8
+ // Monotonic counter that makes every config import URL unique. `Date.now()`
9
+ // alone has millisecond granularity, so two loadConfig calls of the same file
10
+ // within one millisecond (routine on fast CI runners) would otherwise reuse the
11
+ // cached module and miss a rewritten config on disk.
12
+ let configImportCounter = 0;
6
13
 
7
14
  const defaults = {
8
15
  baseBranch: 'master',
@@ -13,41 +20,112 @@ const defaults = {
13
20
  remote: null,
14
21
  adapters: {},
15
22
  engines: { default: { name: 'claude' } },
16
- phases: ['implement', 'gate', 'review', 'address', 'docs', 'git'],
23
+ phases: ['implement', 'docs', 'gate', 'review', 'address', 'pr-description', 'publish'],
24
+ publish: { backend: 'github', draft: true },
17
25
  gate: [],
18
26
  // Commands run once in a newly created worktree before the first phase.
19
27
  // An empty list keeps the generic runner package-manager agnostic.
20
28
  setup: [],
29
+ // Shell used for user-authored setup and gate commands.
30
+ shell: 'sh',
21
31
  maxRounds: 3,
22
32
  // Per-command budget. An implement phase on a real work item routinely
23
33
  // runs past half an hour; a cap that tight kills healthy runs mid-edit.
24
34
  timeoutMs: 60 * 60 * 1000,
35
+ // Optional run-level ceilings. Unknown usage is retained as unknown by the
36
+ // metrics layer and is never silently treated as zero by budget checks.
37
+ budget: {},
25
38
  worktrees: true,
26
39
  worktreeRoot: null,
27
40
  promptDir: '.loop/prompts',
28
41
  runsDir: '.loop/runs',
42
+ // Container execution is opt-in per phase. These are inherited defaults,
43
+ // not an instruction to run every phase in a container.
44
+ hermetic: {
45
+ runtime: 'docker',
46
+ image: null,
47
+ network: [],
48
+ env: [],
49
+ secrets: [],
50
+ },
29
51
  };
30
52
 
31
53
  /**
32
- * Built-in phase definitions.
54
+ * Built-in phase descriptors.
33
55
  *
34
- * `verdict: true` marks a phase whose agent must emit a machine-readable
35
- * verdict; `role: 'repair'` marks a phase that exists to answer one. The
36
- * runner pairs them during normalization, which is what turns a flat list into
37
- * a loop without the config having to describe control flow.
56
+ * A verdict owns its repair transition explicitly. The string phase list stays
57
+ * supported as a shorthand, but adjacency no longer creates a transition.
38
58
  */
39
59
  const builtinPhases = {
40
- implement: { kind: 'agent', prompt: 'implement' },
41
- gate: { kind: 'gate' },
42
- review: { kind: 'agent', prompt: 'review', verdict: true },
43
- address: { kind: 'agent', prompt: 'address', role: 'repair' },
44
- docs: { kind: 'agent', prompt: 'docs' },
45
- // `requiresCleanTree` makes the runner assert a clean worktree before this
46
- // phase runs. The git phase pushes and opens a PR; it must never start over
47
- // uncommitted work an earlier phase left behind, and its prompt forbids it
48
- // from committing, so a dirty tree there is a pipeline error, not something
49
- // for the agent to reconcile.
50
- git: { kind: 'agent', prompt: 'git', requiresCleanTree: true },
60
+ implement: {
61
+ kind: 'agent',
62
+ prompt: 'implement',
63
+ inputs: ['task'],
64
+ outputs: ['commit'],
65
+ postconditions: ['clean-tree', 'head-advanced'],
66
+ permissions: [PERMISSIONS.WRITE_WORKTREE],
67
+ retry: { maxAttempts: 1 },
68
+ },
69
+ gate: {
70
+ kind: 'gate',
71
+ inputs: ['worktree'],
72
+ outputs: ['gate-result'],
73
+ postconditions: ['gate-passes'],
74
+ permissions: [PERMISSIONS.READ_ONLY],
75
+ retry: { maxAttempts: 1 },
76
+ },
77
+ review: {
78
+ kind: 'agent',
79
+ prompt: 'review',
80
+ verdict: true,
81
+ role: 'verdict',
82
+ inputs: ['commit', 'gate-result'],
83
+ outputs: ['verdict'],
84
+ postconditions: ['verdict-recorded'],
85
+ permissions: [PERMISSIONS.READ_ONLY],
86
+ retry: { maxAttempts: 1 },
87
+ repair: ['address', { name: 'gate', recheck: true }],
88
+ },
89
+ address: {
90
+ kind: 'agent',
91
+ prompt: 'address',
92
+ role: 'repair',
93
+ inputs: ['verdict'],
94
+ outputs: ['commit', 'rebuttal'],
95
+ postconditions: ['clean-tree', 'head-advanced-or-rebuttal'],
96
+ permissions: [PERMISSIONS.WRITE_WORKTREE],
97
+ retry: { maxAttempts: 1 },
98
+ },
99
+ docs: {
100
+ kind: 'agent',
101
+ prompt: 'docs',
102
+ optional: true,
103
+ inputs: ['commit'],
104
+ outputs: ['commit'],
105
+ permissions: [PERMISSIONS.WRITE_WORKTREE],
106
+ retry: { maxAttempts: 1 },
107
+ },
108
+ // The description agent may only author the run-dir PR description. The
109
+ // driver owns all remote mutations in the following publish phase.
110
+ 'pr-description': {
111
+ kind: 'agent',
112
+ prompt: 'pr-description',
113
+ requiresCleanTree: true,
114
+ inputs: ['commit'],
115
+ outputs: ['pull-request-description'],
116
+ postconditions: ['clean-tree', 'head-unchanged', 'pr-description-valid'],
117
+ permissions: [PERMISSIONS.READ_ONLY],
118
+ retry: { maxAttempts: 1 },
119
+ },
120
+ publish: {
121
+ kind: 'publish',
122
+ requiresCleanTree: true,
123
+ inputs: ['pull-request-description'],
124
+ outputs: ['pull-request'],
125
+ postconditions: ['remote-matches', 'pull-request-verified'],
126
+ permissions: [PERMISSIONS.PUBLISH],
127
+ retry: { maxAttempts: 1 },
128
+ },
51
129
  };
52
130
 
53
131
  async function exists(path) {
@@ -63,58 +141,142 @@ function toPhase(entry, index) {
63
141
  if (typeof entry === 'string') {
64
142
  const builtin = builtinPhases[entry];
65
143
  if (!builtin) throw new Error(`Unknown phase "${entry}"; define it as an object to add a custom phase.`);
66
- return { name: entry, ...builtin };
144
+ entry = { name: entry };
67
145
  }
68
146
  if (!entry?.name) throw new Error(`Phase at position ${index} is missing a name.`);
69
147
  const builtin = builtinPhases[entry.name] ?? {};
70
- const phase = { ...builtin, ...entry };
71
- if (!['agent', 'gate'].includes(phase.kind)) {
72
- throw new Error(`Phase "${phase.name}" needs kind "agent" or "gate".`);
148
+ const phase = { optional: false, ...builtin, ...entry };
149
+ // Only an explicit boolean true permits skipping a custom phase. This keeps
150
+ // configuration typos such as `optional: 'false'` required by default.
151
+ phase.optional = phase.optional === true;
152
+ if (!['agent', 'gate', 'publish'].includes(phase.kind)) {
153
+ throw new Error(`Phase "${phase.name}" needs kind "agent", "gate", or "publish".`);
73
154
  }
74
155
  if (phase.kind === 'agent' && !phase.prompt) phase.prompt = phase.name;
156
+ for (const field of ['inputs', 'outputs', 'postconditions', 'permissions']) {
157
+ if (phase[field] === undefined) phase[field] = [];
158
+ if (!Array.isArray(phase[field]) || phase[field].some((value) => typeof value !== 'string')) {
159
+ throw new Error(`Phase "${phase.name}" has invalid ${field}; expected an array of strings.`);
160
+ }
161
+ }
162
+ if (phase.permissions.some((permission) => !Object.values(PERMISSIONS).includes(permission))) {
163
+ throw new Error(`Phase "${phase.name}" has invalid permissions; expected ${Object.values(PERMISSIONS).join(', ')}.`);
164
+ }
165
+ if (phase.permissions.length === 0) {
166
+ phase.permissions = [phase.kind === 'publish' ? PERMISSIONS.PUBLISH : PERMISSIONS.READ_ONLY];
167
+ }
168
+ if (phase.retry === undefined) phase.retry = { maxAttempts: 1 };
169
+ if (!phase.retry || typeof phase.retry !== 'object' || Array.isArray(phase.retry)) {
170
+ throw new Error(`Phase "${phase.name}" has an invalid retry descriptor.`);
171
+ }
172
+ phase.retry = { maxAttempts: 1, ...phase.retry };
173
+ if (!Number.isInteger(phase.retry.maxAttempts) || phase.retry.maxAttempts < 1) {
174
+ throw new Error(`Phase "${phase.name}" has an invalid retry.maxAttempts; expected a positive integer.`);
175
+ }
75
176
  return phase;
76
177
  }
77
178
 
78
179
  /**
79
- * Collapse a flat phase list into the shape the runner executes.
180
+ * Normalize phase descriptors and their explicit repair transitions.
80
181
  *
81
- * A verdict phase absorbs the repair phases that follow it, plus any gate that
82
- * immediately preceded it, so `[gate, review, address]` becomes "gate, then
83
- * review, and while the verdict asks for changes: address, gate, review again".
84
- * Declaring the loop this way keeps the config readable while leaving the
85
- * iteration cap in the runner where it belongs.
182
+ * Legacy lists such as `[gate, review, address]` continue to work because the
183
+ * built-in review descriptor names `address` and the gate recheck explicitly.
184
+ * A custom verdict phase must declare its own `repair` list; merely placing a
185
+ * repair phase after it no longer changes control flow.
86
186
  */
87
187
  export function normalizePhases(entries, { maxRounds }) {
88
188
  const list = entries.map(toPhase);
89
- const result = [];
189
+ const phaseIndexes = new Map();
190
+ list.forEach((phase, index) => {
191
+ if (!phaseIndexes.has(phase.name)) phaseIndexes.set(phase.name, index);
192
+ });
193
+ const consumedRepairs = new Set();
194
+
195
+ function resolveRepair(reference, parentName, index, { allowMissing = false } = {}) {
196
+ const referenceName = typeof reference === 'string' ? reference : reference?.name;
197
+ const sourceIndex = referenceName ? phaseIndexes.get(referenceName) : undefined;
198
+ if (sourceIndex === undefined && allowMissing) return null;
199
+ const source = sourceIndex === undefined
200
+ ? (referenceName ? builtinPhases[referenceName] : null)
201
+ : list[sourceIndex];
202
+ if (sourceIndex === undefined && !source && typeof reference === 'string') {
203
+ throw new Error(`Phase "${parentName}" references unknown repair phase "${referenceName}".`);
204
+ }
205
+ const descriptor = typeof reference === 'string'
206
+ ? { name: referenceName, ...source }
207
+ : { name: referenceName, ...source, ...reference };
208
+ const repair = toPhase(descriptor, index);
209
+ if (sourceIndex !== undefined && list[sourceIndex].role === 'repair') consumedRepairs.add(sourceIndex);
210
+ if (repair.verdict) {
211
+ throw new Error(`Phase "${parentName}" cannot repair verdict phase "${repair.name}".`);
212
+ }
213
+ return repair;
214
+ }
215
+
90
216
  for (let index = 0; index < list.length; index += 1) {
91
217
  const phase = list[index];
92
- if (phase.role === 'repair') {
93
- throw new Error(`Phase "${phase.name}" is a repair phase and must follow a phase that emits a verdict.`);
218
+ if (!phase.verdict) continue;
219
+ const repair = phase.repair ?? [];
220
+ if (!Array.isArray(repair)) {
221
+ throw new Error(`Phase "${phase.name}" has an invalid repair transition; expected an array.`);
94
222
  }
95
- if (!phase.verdict) {
96
- result.push(phase);
97
- continue;
98
- }
99
- const repair = [];
100
- let next = index + 1;
101
- while (next < list.length && list[next].role === 'repair') {
102
- repair.push(list[next]);
103
- next += 1;
223
+ phase.maxRounds = phase.maxRounds ?? phase.retry.maxRounds ?? maxRounds;
224
+ const allowMissing = phase.repair === builtinPhases[phase.name]?.repair;
225
+ const resolvedRepair = repair
226
+ .map((reference, repairIndex) => resolveRepair(reference, phase.name, repairIndex, { allowMissing }))
227
+ .filter(Boolean);
228
+ const hasRepairTransition = resolvedRepair.some((repairPhase) => !repairPhase.recheck);
229
+ phase.repair = resolvedRepair.filter((repairPhase) => !repairPhase.recheck || hasRepairTransition);
230
+ }
231
+
232
+ const result = [];
233
+ for (let index = 0; index < list.length; index += 1) {
234
+ const phase = list[index];
235
+ 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.`);
104
237
  }
105
- const precedingGate = result.at(-1)?.kind === 'gate' ? result.at(-1) : null;
106
- result.push({
107
- ...phase,
108
- maxRounds: phase.maxRounds ?? maxRounds,
109
- repair: repair.length
110
- ? [...repair, ...(precedingGate ? [{ ...precedingGate, recheck: true }] : [])]
111
- : [],
112
- });
113
- index = next - 1;
238
+ if (!consumedRepairs.has(index)) result.push(phase);
114
239
  }
115
240
  return result;
116
241
  }
117
242
 
243
+ function assertPublishablePhaseOrder(resolvedPhases) {
244
+ const isPublishingPhase = (phase) => phase.kind === 'publish'
245
+ || phase.requiresCleanTree === true
246
+ || phase.outputs.includes('pull-request')
247
+ || phase.outputs.includes('pull-request-description');
248
+ let violation;
249
+
250
+ for (let publishingIndex = 0; publishingIndex < resolvedPhases.length; publishingIndex += 1) {
251
+ if (!isPublishingPhase(resolvedPhases[publishingIndex])) continue;
252
+
253
+ const verdictIndex = resolvedPhases.findLastIndex((phase, index) => index < publishingIndex
254
+ && (phase.role === 'verdict' || phase.verdict === true));
255
+ if (verdictIndex === -1) continue;
256
+
257
+ const offenders = resolvedPhases
258
+ .slice(verdictIndex + 1, publishingIndex)
259
+ .filter((phase) => phase.outputs.includes('commit'))
260
+ .map((phase) => phase.name);
261
+ if (!offenders.length) continue;
262
+
263
+ violation = { verdictIndex, publishingIndex, offenders };
264
+ }
265
+
266
+ if (violation) {
267
+ const { verdictIndex, publishingIndex, offenders } = violation;
268
+ throw new Error(
269
+ `Phase order publishes an unreviewed tree: ${offenders.join(', ')} `
270
+ + `commit${offenders.length > 1 ? 's' : ''} after the `
271
+ + `"${resolvedPhases[verdictIndex].name}" verdict but before `
272
+ + `"${resolvedPhases[publishingIndex].name}" publishes. A commit after `
273
+ + `approval advances HEAD off the approved SHA and stalls publishing. Move `
274
+ + `${offenders.join(', ')} before the gate/review loop `
275
+ + `(e.g. ['implement', 'docs', 'gate', 'review', 'address', 'pr-description', 'publish']).`,
276
+ );
277
+ }
278
+ }
279
+
118
280
  function normalizeAgent(entry, phaseName, customAdapters) {
119
281
  const agent = typeof entry === 'string' ? { name: entry } : entry;
120
282
  if (!agent || typeof agent !== 'object' || Array.isArray(agent)) {
@@ -146,14 +308,56 @@ function normalizeEngines(engines, customAdapters) {
146
308
  return normalized;
147
309
  }
148
310
 
149
- export async function loadConfig(cwd, overrides = {}, configPath) {
311
+ function normalizeBudget(budget) {
312
+ if (!budget || typeof budget !== 'object' || Array.isArray(budget)) {
313
+ throw new Error('`budget` must be an object with optional tokens, usd, and wallClockMs limits.');
314
+ }
315
+ const normalized = {};
316
+ for (const field of ['tokens', 'usd', 'wallClockMs']) {
317
+ const value = budget[field];
318
+ if (value === undefined) continue;
319
+ const valid = typeof value === 'number' && Number.isFinite(value) && value >= 0
320
+ && (field !== 'tokens' || Number.isInteger(value));
321
+ if (!valid) {
322
+ const kind = field === 'tokens' ? 'a nonnegative integer' : 'a nonnegative finite number';
323
+ throw new Error(`\`budget.${field}\` must be ${kind}.`);
324
+ }
325
+ normalized[field] = value;
326
+ }
327
+ return normalized;
328
+ }
329
+
330
+ async function importConfigFile(cwd, configPath) {
150
331
  const path = configPath ? resolve(configPath) : join(cwd, 'loop.config.mjs');
151
332
  if (configPath && !(await exists(path))) {
152
333
  throw new Error(`Loop config file does not exist: ${path}`);
153
334
  }
154
- const configured = (await exists(path))
155
- ? ((await import(`${pathToFileURL(path).href}?t=${Date.now()}`)).default ?? {})
335
+ return (await exists(path))
336
+ ? ((await import(`${pathToFileURL(path).href}?t=${Date.now()}-${configImportCounter++}`)).default ?? {})
156
337
  : {};
338
+ }
339
+
340
+ /**
341
+ * Resolve only the configured runs directory, skipping pipeline validation.
342
+ *
343
+ * Read-only operations (status/inspect/list/metrics) must inspect saved runs
344
+ * even when the current pipeline config is unrunnable — for example after a
345
+ * config change leaves a gate phase without commands, which `loadConfig`
346
+ * rejects. They need the runs location, not a runnable pipeline, so this reads
347
+ * `runsDir` from the config file (or the default) without normalizing engines
348
+ * or phases.
349
+ */
350
+ export async function loadRunsDir(cwd, configPath) {
351
+ const configured = await importConfigFile(cwd, configPath);
352
+ const runsDir = configured.runsDir ?? defaults.runsDir;
353
+ if (typeof runsDir !== 'string' || !runsDir) {
354
+ throw new Error('`runsDir` must be a non-empty string.');
355
+ }
356
+ return runsDir;
357
+ }
358
+
359
+ export async function loadConfig(cwd, overrides = {}, configPath) {
360
+ const configured = await importConfigFile(cwd, configPath);
157
361
  const merged = {
158
362
  ...defaults,
159
363
  ...configured,
@@ -162,11 +366,32 @@ export async function loadConfig(cwd, overrides = {}, configPath) {
162
366
  engines: { ...defaults.engines, ...configured.engines, ...overrides.engines },
163
367
  };
164
368
  merged.engines = normalizeEngines(merged.engines, merged.adapters);
369
+ merged.budget = normalizeBudget(merged.budget);
370
+ merged.hermetic = normalizeHermeticConfig(merged.hermetic);
371
+ if (!merged.publish || typeof merged.publish !== 'object' || Array.isArray(merged.publish)) {
372
+ throw new Error('`publish` must be an object with backend and draft settings.');
373
+ }
374
+ if (merged.publish.backend !== undefined
375
+ && typeof merged.publish.backend !== 'string'
376
+ && (typeof merged.publish.backend !== 'object' || merged.publish.backend === null)) {
377
+ throw new Error('`publish.backend` must be "github" or a backend object.');
378
+ }
379
+ if (merged.publish.draft !== undefined && typeof merged.publish.draft !== 'boolean') {
380
+ throw new Error('`publish.draft` must be a boolean.');
381
+ }
165
382
  if (!Array.isArray(merged.setup) || merged.setup.some((command) => typeof command !== 'string')) {
166
383
  throw new Error('`setup` must be an array of command strings.');
167
384
  }
385
+ if (typeof merged.shell !== 'string' || !merged.shell.trim()) {
386
+ throw new Error('`shell` must be a non-empty string.');
387
+ }
168
388
  merged.resolvedPhases = normalizePhases(merged.phases, { maxRounds: merged.maxRounds });
169
- if (merged.resolvedPhases.some((phase) => phase.kind === 'gate' && !(phase.commands ?? merged.gate).length)) {
389
+ for (const phase of merged.resolvedPhases.flatMap((entry) => [entry, ...(entry.repair ?? [])])) {
390
+ phase.hermetic = resolvePhaseHermetic(phase.hermetic, merged.hermetic, phase.kind);
391
+ }
392
+ assertPublishablePhaseOrder(merged.resolvedPhases);
393
+ const allResolvedPhases = merged.resolvedPhases.flatMap((phase) => [phase, ...(phase.repair ?? [])]);
394
+ if (allResolvedPhases.some((phase) => phase.kind === 'gate' && !(phase.commands ?? merged.gate).length)) {
170
395
  throw new Error('The pipeline has a gate phase but no gate commands; set `gate: [...]` in loop.config.mjs.');
171
396
  }
172
397
  return merged;
package/src/git.mjs CHANGED
@@ -18,6 +18,10 @@ export class GitFacade {
18
18
  return this.output(['rev-parse', '--show-toplevel']);
19
19
  }
20
20
 
21
+ async commonDir() {
22
+ return this.output(['rev-parse', '--git-common-dir']);
23
+ }
24
+
21
25
  async currentBranch() {
22
26
  return this.output(['symbolic-ref', '--quiet', '--short', 'HEAD']);
23
27
  }
@@ -34,6 +38,24 @@ export class GitFacade {
34
38
  return Boolean(await this.status());
35
39
  }
36
40
 
41
+ async push(remote, branch, { setUpstream = true, ...options } = {}) {
42
+ return this.run([
43
+ 'push',
44
+ ...(setUpstream ? ['--set-upstream'] : []),
45
+ remote,
46
+ branch,
47
+ ], options);
48
+ }
49
+
50
+ async lsRemote(remote, branch, options = {}) {
51
+ const output = await this.output(['ls-remote', remote, `refs/heads/${branch}`], options);
52
+ const sha = output.split(/\s+/)[0];
53
+ if (!/^[0-9a-f]{40}$/i.test(sha)) {
54
+ throw new Error(`Remote branch ${remote}/${branch} has no resolvable SHA.`);
55
+ }
56
+ return sha;
57
+ }
58
+
37
59
  async localBranchExists(branch) {
38
60
  try {
39
61
  await this.run(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
@@ -51,6 +73,10 @@ export class GitFacade {
51
73
  return (await this.remotes()).includes(remote);
52
74
  }
53
75
 
76
+ async remoteUrl(remote) {
77
+ return this.output(['remote', 'get-url', remote]);
78
+ }
79
+
54
80
  async defaultRemote() {
55
81
  const remotes = await this.remotes();
56
82
  if (remotes.includes('origin')) return 'origin';
@@ -0,0 +1,160 @@
1
+ const DEFAULTS = {
2
+ runtime: 'docker',
3
+ image: null,
4
+ networks: [],
5
+ env: [],
6
+ secrets: [],
7
+ };
8
+
9
+ function normalizeNames(value, field) {
10
+ const names = value === undefined ? [] : (Array.isArray(value) ? value : [value]);
11
+ if (names.some((name) => typeof name !== 'string' || !name)) {
12
+ throw new Error(`Hermetic ${field} must contain non-empty strings.`);
13
+ }
14
+ return [...new Set(names)];
15
+ }
16
+
17
+ function normalizeNetworkNames(value) {
18
+ const networks = normalizeNames(value, 'network names');
19
+ if (networks.includes('none') && networks.length > 1) {
20
+ throw new Error('Hermetic network policy cannot combine "none" with an allowed network.');
21
+ }
22
+ return networks;
23
+ }
24
+
25
+ /** Normalize the run-level defaults without enabling container execution. */
26
+ export function normalizeHermeticConfig(value) {
27
+ if (value === undefined || value === null || value === false) return { ...DEFAULTS };
28
+ if (typeof value !== 'object' || Array.isArray(value)) {
29
+ throw new Error('`hermetic` must be an object.');
30
+ }
31
+ const normalized = {
32
+ ...DEFAULTS,
33
+ ...value,
34
+ networks: value.networks ?? value.network ?? DEFAULTS.networks,
35
+ };
36
+ if (typeof normalized.runtime !== 'string' || !normalized.runtime) {
37
+ throw new Error('`hermetic.runtime` must be a non-empty command name.');
38
+ }
39
+ if (normalized.image !== null && (typeof normalized.image !== 'string' || !normalized.image)) {
40
+ throw new Error('`hermetic.image` must be a non-empty image name or null.');
41
+ }
42
+ normalized.networks = normalizeNetworkNames(normalized.networks);
43
+ normalized.env = normalizeNames(normalized.env, 'environment names');
44
+ normalized.secrets = normalizeNames(normalized.secrets, 'secret names');
45
+ delete normalized.network;
46
+ return normalized;
47
+ }
48
+
49
+ /**
50
+ * Resolve a phase's opt-in descriptor against run-level defaults.
51
+ * `undefined` and `false` deliberately mean host execution.
52
+ */
53
+ export function resolvePhaseHermetic(value, defaults, phaseKind) {
54
+ if (value === undefined || value === false || value === null) return null;
55
+ if (value !== true && (typeof value !== 'object' || Array.isArray(value))) {
56
+ throw new Error('Phase hermetic execution must be true, false, or an object.');
57
+ }
58
+ const override = value === true ? {} : value;
59
+ const resolved = normalizeHermeticConfig({
60
+ ...defaults,
61
+ ...override,
62
+ ...(override.network !== undefined || override.networks !== undefined
63
+ ? { networks: override.networks ?? override.network }
64
+ : {}),
65
+ });
66
+ if (!resolved.image) {
67
+ throw new Error('Hermetic phases require `hermetic.image` to be configured.');
68
+ }
69
+ if (resolved.secrets.length && phaseKind !== 'publish') {
70
+ throw new Error('Hermetic secrets may only be declared by the publish phase.');
71
+ }
72
+ return resolved;
73
+ }
74
+
75
+ function mountArg(path, mode) {
76
+ return `type=bind,src=${path},dst=${path}${mode === 'ro' ? ',readonly' : ''}`;
77
+ }
78
+
79
+ function uniqueMounts(mounts) {
80
+ const byPath = new Map();
81
+ for (const mount of mounts) {
82
+ const current = byPath.get(mount.path);
83
+ if (!current || (current.mode === 'ro' && mount.mode === 'rw')) byPath.set(mount.path, mount);
84
+ }
85
+ return [...byPath.values()];
86
+ }
87
+
88
+ /**
89
+ * Build a runtime invocation around an adapter command. Paths intentionally
90
+ * stay absolute: each host path is bind-mounted at the same path in the
91
+ * container, so BYO adapters need no container-specific contract.
92
+ */
93
+ export function hermeticInvocation({
94
+ settings,
95
+ command,
96
+ args,
97
+ cwd,
98
+ mounts = [],
99
+ env = process.env,
100
+ }) {
101
+ const runtimeArgs = ['run', '--rm', '--workdir', cwd];
102
+ const networks = settings.networks.length ? settings.networks : ['none'];
103
+ for (const network of networks) runtimeArgs.push('--network', network);
104
+ for (const mount of uniqueMounts(mounts)) runtimeArgs.push('--mount', mountArg(mount.path, mount.mode));
105
+
106
+ const environment = { PATH: env.PATH ?? '/usr/bin:/bin' };
107
+ for (const name of settings.secrets) {
108
+ if (env[name] === undefined) throw new Error(`Hermetic secret "${name}" is not set in the host environment.`);
109
+ }
110
+ for (const name of [...settings.env, ...settings.secrets]) {
111
+ if (env[name] !== undefined) environment[name] = env[name];
112
+ }
113
+ for (const name of Object.keys(environment)) runtimeArgs.push('--env', name);
114
+
115
+ runtimeArgs.push(settings.image, command, ...args);
116
+ return {
117
+ command: settings.runtime,
118
+ args: runtimeArgs,
119
+ env: environment,
120
+ policy: {
121
+ runtime: settings.runtime,
122
+ image: settings.image,
123
+ networks: [...settings.networks],
124
+ env: [...settings.env],
125
+ secrets: [...settings.secrets],
126
+ },
127
+ };
128
+ }
129
+
130
+ export function hermeticEnvironment(settings, source = process.env) {
131
+ const environment = { PATH: source.PATH ?? '/usr/bin:/bin' };
132
+ for (const name of settings.secrets) {
133
+ if (source[name] === undefined) throw new Error(`Hermetic secret "${name}" is not set in the host environment.`);
134
+ }
135
+ for (const name of [...settings.env, ...settings.secrets]) {
136
+ if (source[name] !== undefined) environment[name] = source[name];
137
+ }
138
+ return environment;
139
+ }
140
+
141
+ export function hermeticSnapshot(config) {
142
+ const phases = config.resolvedPhases
143
+ .flatMap((phase) => [phase, ...(phase.repair ?? [])])
144
+ .map((phase) => ({
145
+ name: phase.name,
146
+ enabled: Boolean(phase.hermetic),
147
+ ...(phase.hermetic ? {
148
+ runtime: phase.hermetic.runtime,
149
+ image: phase.hermetic.image,
150
+ networks: [...phase.hermetic.networks],
151
+ env: [...phase.hermetic.env],
152
+ secrets: [...phase.hermetic.secrets],
153
+ } : {}),
154
+ }));
155
+ return {
156
+ runtime: config.hermetic.runtime,
157
+ image: config.hermetic.image,
158
+ phases,
159
+ };
160
+ }
package/src/index.mjs CHANGED
@@ -1,6 +1,27 @@
1
1
  export { runLoop } from './pipeline.mjs';
2
- export { loadConfig, normalizePhases, builtinPhases, defaults } from './config.mjs';
2
+ export { loadConfig, loadRunsDir, normalizePhases, builtinPhases, defaults } from './config.mjs';
3
3
  export { adapterFor, engineForPhase, adapters } from './adapters.mjs';
4
4
  export { renderPrompt, loadTemplate, interpolate } from './prompts.mjs';
5
5
  export { parseVerdict, readVerdict, formatFindings, APPROVED, CHANGES_REQUESTED } from './verdict.mjs';
6
6
  export { RunState, slugFor } from './state.mjs';
7
+ export {
8
+ computeRunMetrics,
9
+ computeAggregateMetrics,
10
+ readRunManifest,
11
+ readRunManifests,
12
+ } from './metrics.mjs';
13
+ export {
14
+ resolveRunsDir,
15
+ getRunStatus,
16
+ listRunStatuses,
17
+ getAggregateMetrics,
18
+ runOperationalCommand,
19
+ listRuns,
20
+ getRun,
21
+ inspectRun,
22
+ cancelRun,
23
+ cleanRuns,
24
+ doctor,
25
+ } from './operations.mjs';
26
+ export { githubBackend } from './publish.mjs';
27
+ export { gitlabBackend, glabTransport, parseGitLabRemoteUrl } from './backends/gitlab.mjs';