@syntax-syllogism/aloop 0.5.2 → 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/CHANGELOG.md +43 -0
- package/README.md +49 -15
- package/bin/loop.mjs +197 -7
- package/package.json +7 -1
- package/presets/work-item/loop.config.mjs +1 -1
- package/presets/work-item/prompts/docs.md +6 -4
- package/presets/work-item/prompts/pr-description.md +27 -0
- package/presets/work-item/prompts/review.md +6 -1
- package/prompts/address.md +2 -1
- package/prompts/docs.md +5 -3
- package/prompts/{git.md → pr-description.md} +11 -8
- package/prompts/review.md +9 -4
- package/src/adapters.mjs +84 -9
- package/src/backends/gitlab.mjs +231 -0
- package/src/command.mjs +156 -6
- package/src/config.mjs +278 -53
- package/src/git.mjs +26 -0
- package/src/hermetic.mjs +160 -0
- package/src/index.mjs +22 -1
- package/src/manifest.mjs +67 -0
- package/src/metrics.mjs +251 -0
- package/src/operations.mjs +651 -0
- package/src/pipeline.mjs +505 -427
- package/src/policy.mjs +63 -0
- package/src/publish.mjs +183 -0
- package/src/reporter.mjs +102 -0
- package/src/runner.mjs +509 -0
- package/src/state.mjs +338 -16
- package/src/verdict.mjs +36 -2
- package/src/worktree.mjs +33 -0
- package/presets/work-item/prompts/git.md +0 -44
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', '
|
|
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
|
|
54
|
+
* Built-in phase descriptors.
|
|
33
55
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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: {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
*
|
|
180
|
+
* Normalize phase descriptors and their explicit repair transitions.
|
|
80
181
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
|
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.
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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';
|
package/src/hermetic.mjs
ADDED
|
@@ -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';
|