@cxi-lmai/ci-agent-platform 3.1.4 → 3.2.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/README.md +11 -6
- package/bin/init.mjs +200 -8
- package/package.json +1 -1
- package/payload/INSTALL.md +23 -0
- package/payload/ci-templates/scripts/lib/pipeline-common.sh +39 -4
- package/payload/ci-templates/scripts/review-fix.sh +3 -0
- package/payload/ci-templates/scripts/review.sh +8 -1
- package/payload/ci-templates/scripts/test-fix.sh +3 -0
package/README.md
CHANGED
|
@@ -170,12 +170,17 @@ Re-run `npx @cxi-lmai/ci-agent-platform` to take a newer release. It records wha
|
|
|
170
170
|
it installed in `.claude/pipeline-install.json`, including a checksum per file,
|
|
171
171
|
so it can tell a file you edited from one it wrote.
|
|
172
172
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
173
|
+
A re-run reconciles those checksums against the new release and the copies in
|
|
174
|
+
your repository. A file you never touched is brought up to date in place, with
|
|
175
|
+
no prompt. Everything else is left exactly as it is and written to
|
|
176
|
+
`.claude/onboarding-state.md` as a decision list: files you edited that also
|
|
177
|
+
changed upstream, files new in this release, and files the release dropped.
|
|
178
|
+
Open Claude Code and ask it to finish the upgrade; it works through that list
|
|
179
|
+
one file at a time, showing a diff before it touches anything. A file you
|
|
180
|
+
edited that this release does not change is never mentioned at all.
|
|
181
|
+
|
|
182
|
+
`pipeline-config.md`, `CLAUDE.md`, `docs/` and `.claude/memory/` carry no
|
|
183
|
+
checksum. They are project-owned, and the installer never reclaims them.
|
|
179
184
|
|
|
180
185
|
To remove the pipeline: delete `.claude/agents/`, `.claude/skills/`,
|
|
181
186
|
`.claude/templates/`, `.claude/pipeline-config.md`,
|
package/bin/init.mjs
CHANGED
|
@@ -70,17 +70,104 @@ function sha256(file) {
|
|
|
70
70
|
return `sha256:${createHash('sha256').update(fs.readFileSync(file)).digest('hex')}`
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
// Payload-relative paths, sorted, so the manifest is
|
|
73
|
+
// Payload-relative paths, posix-separated and sorted, so the manifest is
|
|
74
|
+
// stable across platforms.
|
|
74
75
|
function walk(dir, base = dir) {
|
|
75
76
|
return fs
|
|
76
77
|
.readdirSync(dir, { withFileTypes: true })
|
|
77
78
|
.flatMap((e) => {
|
|
78
79
|
const full = path.join(dir, e.name)
|
|
79
|
-
return e.isDirectory() ? walk(full, base) : [path.relative(base, full)]
|
|
80
|
+
return e.isDirectory() ? walk(full, base) : [path.relative(base, full).split(path.sep).join('/')]
|
|
80
81
|
})
|
|
81
82
|
.sort()
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Where a payload file ends up in the repository, repo-relative, or null when
|
|
87
|
+
* it is never installed (INSTALL.md, the github/ README) or belongs to the
|
|
88
|
+
* other platform. This mirrors step 1 of payload/INSTALL.md and is the only
|
|
89
|
+
* place in bin/ that knows the shape of an installed repository.
|
|
90
|
+
*/
|
|
91
|
+
function destinationOf(rel, plat) {
|
|
92
|
+
const parts = rel.split('/')
|
|
93
|
+
const tail = parts.slice(1).join('/')
|
|
94
|
+
switch (parts[0]) {
|
|
95
|
+
case 'agents':
|
|
96
|
+
return `.claude/agents/${tail}`
|
|
97
|
+
case 'agents-omp':
|
|
98
|
+
return `.omp/agents/${tail}`
|
|
99
|
+
case 'skills':
|
|
100
|
+
return `.claude/skills/${tail}`
|
|
101
|
+
case 'templates':
|
|
102
|
+
return `.claude/templates/${tail}`
|
|
103
|
+
case 'ci-templates':
|
|
104
|
+
if (parts[1] === 'scripts') return `.claude-pipeline/${tail}`
|
|
105
|
+
if (parts[1] === 'github') {
|
|
106
|
+
return plat === 'github' && rel.endsWith('.yml') ? `.github/workflows/${parts[2]}` : null
|
|
107
|
+
}
|
|
108
|
+
if (parts.length === 2 && rel.endsWith('.gitlab-ci.yml')) {
|
|
109
|
+
return plat === 'gitlab' ? `.claude-pipeline/${parts[1]}` : null
|
|
110
|
+
}
|
|
111
|
+
return null
|
|
112
|
+
default:
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Reconcile a previous install against this release. Three checksums decide
|
|
119
|
+
* every file: the one the previous run recorded, the one this release ships,
|
|
120
|
+
* and the one on disk.
|
|
121
|
+
*
|
|
122
|
+
* Only the unambiguous case is acted on: an installed file still byte-identical
|
|
123
|
+
* to what was recorded is a file nobody edited, so a changed release can
|
|
124
|
+
* replace it with no prompt. Everything else is reported and left alone,
|
|
125
|
+
* because deciding it needs judgement about this repository, which belongs to
|
|
126
|
+
* the init skill and the human.
|
|
127
|
+
*/
|
|
128
|
+
function reconcile(recorded, digests, plat) {
|
|
129
|
+
const refreshed = []
|
|
130
|
+
const conflicted = []
|
|
131
|
+
const added = []
|
|
132
|
+
const dropped = []
|
|
133
|
+
let kept = 0
|
|
134
|
+
|
|
135
|
+
for (const [rel, shipped] of digests) {
|
|
136
|
+
const dest = destinationOf(rel, plat)
|
|
137
|
+
if (!Object.hasOwn(recorded, rel)) {
|
|
138
|
+
// No previous install recorded this path. Absent on disk it is simply new
|
|
139
|
+
// upstream; present on disk it is a copy of unknown provenance, which is
|
|
140
|
+
// only overwritten after someone has looked at the diff.
|
|
141
|
+
if (!dest) continue
|
|
142
|
+
if (!fs.existsSync(dest)) added.push(rel)
|
|
143
|
+
else if (sha256(dest) !== shipped) conflicted.push({ rel, dest })
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
// Not installed: an agent phase 1 skipped, or the other platform's CI file.
|
|
147
|
+
if (!dest || !fs.existsSync(dest)) continue
|
|
148
|
+
const onDisk = sha256(dest)
|
|
149
|
+
if (onDisk === shipped) continue // already current, whatever the record says
|
|
150
|
+
if (recorded[rel] === shipped) {
|
|
151
|
+
kept += 1 // locally edited, but this release does not touch the file
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
if (onDisk !== recorded[rel]) {
|
|
155
|
+
conflicted.push({ rel, dest }) // edited here and changed upstream: the human decides
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
fs.copyFileSync(path.join(PAYLOAD, rel), dest)
|
|
159
|
+
refreshed.push(dest)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const rel of Object.keys(recorded)) {
|
|
163
|
+
if (digests.has(rel)) continue
|
|
164
|
+
const dest = destinationOf(rel, plat)
|
|
165
|
+
if (dest && fs.existsSync(dest)) dropped.push(dest)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return { refreshed, conflicted, added, dropped, kept }
|
|
169
|
+
}
|
|
170
|
+
|
|
84
171
|
/**
|
|
85
172
|
* Classify the remote host without ever surfacing the URL. A remote can embed
|
|
86
173
|
* an access token in its userinfo, so the URL is read, matched, and dropped:
|
|
@@ -142,6 +229,22 @@ if (fs.existsSync(configPath) && !fs.existsSync(manifestPath) && !FORCE) {
|
|
|
142
229
|
)
|
|
143
230
|
}
|
|
144
231
|
|
|
232
|
+
// The checksums a previous run recorded are the only evidence of what the
|
|
233
|
+
// previous release put on disk, so they are read before anything is written.
|
|
234
|
+
// A manifest that is not readable JSON counts as no manifest: this run then
|
|
235
|
+
// records, and never claims to have reconciled anything.
|
|
236
|
+
let prior = null
|
|
237
|
+
if (fs.existsSync(manifestPath)) {
|
|
238
|
+
try {
|
|
239
|
+
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
240
|
+
if (parsed?.payload && typeof parsed.payload === 'object') prior = parsed
|
|
241
|
+
} catch {
|
|
242
|
+
say('Note: .claude/pipeline-install.json is unreadable. It is rewritten from scratch,')
|
|
243
|
+
say('so nothing is reconciled against it this run.')
|
|
244
|
+
say()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
145
248
|
const platform = detectPlatform()
|
|
146
249
|
let githubOptIn = null
|
|
147
250
|
if (platform === 'github') {
|
|
@@ -182,14 +285,72 @@ fs.copyFileSync(payloadInstall, path.join(REPO, installName))
|
|
|
182
285
|
fs.mkdirSync(path.join(REPO, '.claude'), { recursive: true })
|
|
183
286
|
|
|
184
287
|
const payloadFiles = walk(PAYLOAD)
|
|
288
|
+
const digests = new Map(payloadFiles.map((rel) => [rel, sha256(path.join(PAYLOAD, rel))]))
|
|
289
|
+
|
|
290
|
+
// Reconcile before the manifest is rewritten; afterwards the old checksums are
|
|
291
|
+
// gone. Nothing here touches pipeline-config.md, CLAUDE.md, docs/ or
|
|
292
|
+
// .claude/memory/: those are project-owned and init never reclaims them.
|
|
293
|
+
const recon = prior ? reconcile(prior.payload, digests, platform) : null
|
|
294
|
+
|
|
295
|
+
const installed = {}
|
|
296
|
+
for (const rel of payloadFiles) {
|
|
297
|
+
const dest = destinationOf(rel, platform)
|
|
298
|
+
if (dest && fs.existsSync(dest)) installed[rel] = dest
|
|
299
|
+
}
|
|
300
|
+
|
|
185
301
|
const manifest = {
|
|
186
302
|
package: pkg.name,
|
|
187
303
|
version: pkg.version,
|
|
188
304
|
platform,
|
|
189
|
-
payload: Object.fromEntries(
|
|
305
|
+
payload: Object.fromEntries(digests),
|
|
190
306
|
}
|
|
307
|
+
if (Object.keys(installed).length > 0) manifest.installed = installed
|
|
308
|
+
// Phase 2 owns the selection record. A re-run must not erase it.
|
|
309
|
+
if (prior?.selected) manifest.selected = prior.selected
|
|
191
310
|
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
|
192
311
|
|
|
312
|
+
// Reconciliation is only reported once phase 2 has put files in the repository.
|
|
313
|
+
// A second phase-0 run on a repository nobody has installed into has nothing to
|
|
314
|
+
// reconcile, and a report of five zeroes would only read as a warning.
|
|
315
|
+
const reported = recon && Object.keys(installed).length > 0 ? recon : null
|
|
316
|
+
|
|
317
|
+
// An install that has a config is finished, so a re-run of it is an upgrade.
|
|
318
|
+
// Without one, phase 2 was interrupted: the files are reconciled all the same,
|
|
319
|
+
// and the session still continues the install at phase 1.
|
|
320
|
+
const upgrade = reported !== null && fs.existsSync(configPath)
|
|
321
|
+
|
|
322
|
+
// What the model has to act on: the cases reconciliation deliberately refused
|
|
323
|
+
// to decide. An empty list is written as nothing at all, so a clean upgrade
|
|
324
|
+
// leaves the state file as short as a first install.
|
|
325
|
+
const unresolved = reported
|
|
326
|
+
? reported.conflicted.length + reported.added.length + reported.dropped.length
|
|
327
|
+
: 0
|
|
328
|
+
|
|
329
|
+
function stateList(title, entries) {
|
|
330
|
+
if (entries.length === 0) return ''
|
|
331
|
+
return `\n${title}\n${entries.map((e) => `- ${e}`).join('\n')}\n`
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const reconSection = !reported
|
|
335
|
+
? ''
|
|
336
|
+
: `
|
|
337
|
+
## Reconciliation with the previous install
|
|
338
|
+
|
|
339
|
+
Refreshed in place, untouched since they were installed: ${reported.refreshed.length}
|
|
340
|
+
Left alone, edited here and unchanged in this release: ${reported.kept}
|
|
341
|
+
${stateList(
|
|
342
|
+
'Edited here AND changed in this release. Show the diff against the source\n' +
|
|
343
|
+
'copy and ask, one file at a time. Never overwrite without asking:',
|
|
344
|
+
reported.conflicted.map((c) => `${c.dest} (source: ${SRC_DIR}/${c.rel})`),
|
|
345
|
+
)}${stateList(
|
|
346
|
+
'New in this release, not installed here. Install the ones this project\n' +
|
|
347
|
+
'selects, following step 1 of the install instructions:',
|
|
348
|
+
reported.added,
|
|
349
|
+
)}${stateList(
|
|
350
|
+
'Installed here, no longer part of the payload. Offer removal:',
|
|
351
|
+
reported.dropped,
|
|
352
|
+
)}`
|
|
353
|
+
|
|
193
354
|
fs.writeFileSync(
|
|
194
355
|
path.join(REPO, '.claude', 'onboarding-state.md'),
|
|
195
356
|
`# Onboarding state
|
|
@@ -200,13 +361,18 @@ GitHub experimental opt-in: ${githubOptIn === null ? 'not applicable' : githubOp
|
|
|
200
361
|
Source folder: ${SRC_DIR}/
|
|
201
362
|
Install instructions: ${installName}
|
|
202
363
|
|
|
203
|
-
Next action:
|
|
204
|
-
|
|
364
|
+
Next action: ${
|
|
365
|
+
upgrade
|
|
366
|
+
? `this repository is already installed, so phase 1 is not redone.
|
|
367
|
+
Work through the reconciliation below, one file at a time, and stop there.`
|
|
368
|
+
: `phase 1 of ${SRC_DIR}/skills/init-pipeline-config/SKILL.md
|
|
369
|
+
(scan the repository, select agents, draft the config).`
|
|
370
|
+
}
|
|
205
371
|
|
|
206
372
|
This file is gitignored and is deleted at the end of the install. A session
|
|
207
373
|
resuming an interrupted onboarding reads it first and continues from the phase
|
|
208
374
|
recorded here, following the skill rather than improvising.
|
|
209
|
-
`,
|
|
375
|
+
${reconSection}`,
|
|
210
376
|
)
|
|
211
377
|
|
|
212
378
|
// --- handoff ---------------------------------------------------------------
|
|
@@ -220,6 +386,21 @@ say(' .claude/pipeline-install.json')
|
|
|
220
386
|
say(' .claude/onboarding-state.md')
|
|
221
387
|
say(` gitignored ${addedIgnores.length > 0 ? addedIgnores.join(', ') : 'nothing new'}`)
|
|
222
388
|
say(` platform ${platform}${platform === 'github' ? ' (experimental)' : ''}`)
|
|
389
|
+
if (reported) {
|
|
390
|
+
say(` refreshed ${reported.refreshed.length} installed file(s) untouched since the last install`)
|
|
391
|
+
if (reported.kept > 0) {
|
|
392
|
+
say(` kept ${reported.kept} file(s) you edited that this release does not change`)
|
|
393
|
+
}
|
|
394
|
+
if (reported.conflicted.length > 0) {
|
|
395
|
+
say(` conflicts ${reported.conflicted.length} file(s) edited here and changed upstream`)
|
|
396
|
+
}
|
|
397
|
+
if (reported.added.length > 0) {
|
|
398
|
+
say(` new ${reported.added.length} payload file(s) not installed here`)
|
|
399
|
+
}
|
|
400
|
+
if (reported.dropped.length > 0) {
|
|
401
|
+
say(` dropped ${reported.dropped.length} installed file(s) no longer in the payload`)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
223
404
|
if (installName !== 'INSTALL.md') {
|
|
224
405
|
say()
|
|
225
406
|
say(' Note: this repository already had its own INSTALL.md, which was left')
|
|
@@ -229,8 +410,19 @@ if (platform === 'unknown') {
|
|
|
229
410
|
say()
|
|
230
411
|
say(' Note: no git remote found, so the platform is unknown. Phase 1 asks.')
|
|
231
412
|
}
|
|
413
|
+
if (unresolved > 0) {
|
|
414
|
+
say()
|
|
415
|
+
say(` Note: ${unresolved} file(s) need a decision. They are listed in`)
|
|
416
|
+
say(' .claude/onboarding-state.md; the session handles them one at a time.')
|
|
417
|
+
say(' Nothing you edited was overwritten.')
|
|
418
|
+
}
|
|
232
419
|
say()
|
|
233
|
-
|
|
234
|
-
say('
|
|
420
|
+
if (upgrade) {
|
|
421
|
+
say('Next: open Claude Code in this repository and ask it to finish the upgrade.')
|
|
422
|
+
say('It reads the reconciliation list and handles what is left, one file at a time.')
|
|
423
|
+
} else {
|
|
424
|
+
say('Next: open Claude Code in this repository and ask it to install the')
|
|
425
|
+
say('pipeline (any phrasing). It reads the instructions and takes it from here.')
|
|
426
|
+
}
|
|
235
427
|
say()
|
|
236
428
|
say('Nothing has been committed. Nothing has been pushed.')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cxi-lmai/ci-agent-platform",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"description": "Autonomous dev pipeline on plain GitLab CI or GitHub Actions, driven by Claude Code. A labeled issue goes in, an open merge request comes out.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/payload/INSTALL.md
CHANGED
|
@@ -27,6 +27,29 @@ manifest that the upgrade path depends on is written only by the bootstrapper.
|
|
|
27
27
|
Then read `.claude/onboarding-state.md`, which records the detected platform
|
|
28
28
|
and the phase reached.
|
|
29
29
|
|
|
30
|
+
A `## Reconciliation with the previous install` section in that state file
|
|
31
|
+
means the bootstrapper found an earlier install and has already brought every
|
|
32
|
+
file nobody edited up to date. What is left is the list under that heading: it
|
|
33
|
+
exists precisely because each entry needs a decision, and the bootstrapper
|
|
34
|
+
makes none.
|
|
35
|
+
|
|
36
|
+
- **Edited here and changed in this release.** Show the diff between the
|
|
37
|
+
installed file and the source copy named on the same line, one file at a
|
|
38
|
+
time, and ask. The project's version outranks the shipped one.
|
|
39
|
+
- **New in this release.** Install it only if this project's agent selection
|
|
40
|
+
wants it, under step 1's rules. A new agent the project has no use for is
|
|
41
|
+
not installed.
|
|
42
|
+
- **No longer part of the payload.** Offer removal; never delete unasked.
|
|
43
|
+
|
|
44
|
+
The `Next action:` line says what follows. When it reports the repository is
|
|
45
|
+
already installed, this is an upgrade: work that list, skip steps 1 and 2, then
|
|
46
|
+
offer one commit of what changed, message `ci: upgrade ci-agent-platform to
|
|
47
|
+
<version>` with the version read from `.claude/pipeline-install.json`, and
|
|
48
|
+
clean up (`.ci-agent-platform-src/`, the root copy of this file,
|
|
49
|
+
`.claude/onboarding-state.md`) as at the end of a first install. When it points
|
|
50
|
+
at phase 1 instead, an earlier install was interrupted: work the list, then
|
|
51
|
+
continue with step 1 below, which skips what is already in place.
|
|
52
|
+
|
|
30
53
|
## Step 1: distribute the files
|
|
31
54
|
|
|
32
55
|
Let `<src>` be `.ci-agent-platform-src/`.
|
|
@@ -277,10 +277,16 @@ pipe_run_claude() {
|
|
|
277
277
|
# $3 = allowed tools (optional), $4 = model for the main loop (optional,
|
|
278
278
|
# e.g. $PIPE_MODEL_REVIEW; subagents keep their frontmatter models),
|
|
279
279
|
# stdin = extra prompt (optional, usually empty)
|
|
280
|
+
#
|
|
281
|
+
# Always returns 0, so a caller running under `set -e` keeps control over what
|
|
282
|
+
# a failed agent means for its job. The agent's real exit code lands in
|
|
283
|
+
# PIPE_AGENT_RC. A caller that must not read a crashed agent as a clean run
|
|
284
|
+
# checks that variable, or calls pipe_require_agent_ran below.
|
|
280
285
|
local label="$1" skill="$2" tools="${3:-Agent,Read,Write,Edit,Glob,Grep,Bash}" model="${4:-}"
|
|
281
286
|
pipe_require_harness claude
|
|
282
287
|
local stdin_file; stdin_file=$(mktemp)
|
|
283
288
|
cat > "$stdin_file" || true
|
|
289
|
+
PIPE_AGENT_RC=0
|
|
284
290
|
|
|
285
291
|
if [ "$(id -u)" -eq 0 ]; then
|
|
286
292
|
local runuser="pipelinebot"
|
|
@@ -315,7 +321,7 @@ pipe_run_claude() {
|
|
|
315
321
|
} > "$runner"
|
|
316
322
|
chmod +x "$runner"
|
|
317
323
|
chown "$runuser:$runuser" "$runner"
|
|
318
|
-
su -m "$runuser" -s /bin/bash "$runner" ||
|
|
324
|
+
su -m "$runuser" -s /bin/bash "$runner" || PIPE_AGENT_RC=$?
|
|
319
325
|
chown -R root:root "$PWD" 2>/dev/null || true
|
|
320
326
|
rm -f "$runner"
|
|
321
327
|
else
|
|
@@ -327,9 +333,32 @@ pipe_run_claude() {
|
|
|
327
333
|
else
|
|
328
334
|
capture_claude "$label" -- --dangerously-skip-permissions --allowedTools "$tools" -p "$skill" < "$stdin_file"
|
|
329
335
|
fi
|
|
330
|
-
) ||
|
|
336
|
+
) || PIPE_AGENT_RC=$?
|
|
331
337
|
fi
|
|
332
338
|
rm -f "$stdin_file"
|
|
339
|
+
[ "$PIPE_AGENT_RC" -eq 0 ] || pipe_log "agent exited with code $PIPE_AGENT_RC"
|
|
340
|
+
return 0
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
pipe_require_agent_ran() {
|
|
344
|
+
# $1 = file the skill was supposed to write (empty to check the exit code
|
|
345
|
+
# only, for skills whose marker is legitimately optional), $2 = human label.
|
|
346
|
+
# Fails the job when the agent crashed or wrote nothing. Without this an
|
|
347
|
+
# agent that never started is indistinguishable from one that ran and found
|
|
348
|
+
# nothing: every caller reads its marker file, misses it, and falls back to
|
|
349
|
+
# the "all clear" default, so the job goes green without a review, fix, or
|
|
350
|
+
# triage having happened.
|
|
351
|
+
local marker="$1" what="${2:-agent}"
|
|
352
|
+
if [ "${PIPE_AGENT_RC:-0}" -ne 0 ]; then
|
|
353
|
+
pipe_log "ERROR: $what did not complete (exit code $PIPE_AGENT_RC). See the job log above."
|
|
354
|
+
return 1
|
|
355
|
+
fi
|
|
356
|
+
[ -n "$marker" ] || return 0
|
|
357
|
+
if [ ! -s "$marker" ]; then
|
|
358
|
+
pipe_log "ERROR: $what produced no output ($marker is missing or empty)."
|
|
359
|
+
return 1
|
|
360
|
+
fi
|
|
361
|
+
return 0
|
|
333
362
|
}
|
|
334
363
|
|
|
335
364
|
# --- omp invocation -----------------------------------------------------
|
|
@@ -418,6 +447,9 @@ pipe_run_omp() {
|
|
|
418
447
|
# internally), $4 = model for the main loop (optional, e.g.
|
|
419
448
|
# $PIPE_MODEL_REVIEW; subagents keep the models from their own
|
|
420
449
|
# frontmatter), stdin = extra prompt (optional, usually empty).
|
|
450
|
+
#
|
|
451
|
+
# Same contract as pipe_run_claude: always returns 0 and reports the agent's
|
|
452
|
+
# real exit code in PIPE_AGENT_RC, which pipe_require_agent_ran checks.
|
|
421
453
|
local label="$1" skill="$2" tools="${3:-Agent,Read,Write,Edit,Glob,Grep,Bash}" model="${4:-}"
|
|
422
454
|
local omp_tools; omp_tools=$(pipe_translate_tools_to_omp "$tools")
|
|
423
455
|
# Bound the run below the job's own hard kill (see pipe_agent_max_time).
|
|
@@ -431,6 +463,7 @@ pipe_run_omp() {
|
|
|
431
463
|
pipe_require_harness omp
|
|
432
464
|
local stdin_file; stdin_file=$(mktemp)
|
|
433
465
|
cat > "$stdin_file" || true
|
|
466
|
+
PIPE_AGENT_RC=0
|
|
434
467
|
|
|
435
468
|
if [ "$(id -u)" -eq 0 ]; then
|
|
436
469
|
local runuser="pipelinebot"
|
|
@@ -465,7 +498,7 @@ pipe_run_omp() {
|
|
|
465
498
|
} > "$runner"
|
|
466
499
|
chmod +x "$runner"
|
|
467
500
|
chown "$runuser:$runuser" "$runner"
|
|
468
|
-
su -m "$runuser" -s /bin/bash "$runner" ||
|
|
501
|
+
su -m "$runuser" -s /bin/bash "$runner" || PIPE_AGENT_RC=$?
|
|
469
502
|
chown -R root:root "$PWD" 2>/dev/null || true
|
|
470
503
|
rm -f "$runner"
|
|
471
504
|
else
|
|
@@ -477,9 +510,11 @@ pipe_run_omp() {
|
|
|
477
510
|
else
|
|
478
511
|
capture_omp "$label" -- "${max_time_args[@]}" --yolo --no-session --no-title --tools "$omp_tools" -p "$skill" < "$stdin_file"
|
|
479
512
|
fi
|
|
480
|
-
) ||
|
|
513
|
+
) || PIPE_AGENT_RC=$?
|
|
481
514
|
fi
|
|
482
515
|
rm -f "$stdin_file"
|
|
516
|
+
[ "$PIPE_AGENT_RC" -eq 0 ] || pipe_log "agent exited with code $PIPE_AGENT_RC"
|
|
517
|
+
return 0
|
|
483
518
|
}
|
|
484
519
|
|
|
485
520
|
# --- Harness dispatch ---------------------------------------------------
|
|
@@ -41,6 +41,9 @@ echo "$NOTES" | jq -r --arg u "${PIPE_BOT_USER:-}" --arg m "$REVIEW_MARKER" \
|
|
|
41
41
|
# verifies, writes the result JSON, and the review-fix.env marker. On cap it
|
|
42
42
|
# runs the shared postmortem flow (writing postmortem.md + postmortem.env).
|
|
43
43
|
pipe_run_agent coder "/fix-review-findings" "Agent,Read,Write,Edit,Glob,Grep,Bash" "$PIPE_MODEL_CODE" < /dev/null
|
|
44
|
+
# A crashed agent writes no marker, which step 6 would read as "nothing changed"
|
|
45
|
+
# and step 7 would report as a completed fix run that had nothing to do.
|
|
46
|
+
pipe_require_agent_ran "" "The review-fix agent" || exit 1
|
|
44
47
|
|
|
45
48
|
# 6. Read the marker the skill wrote.
|
|
46
49
|
RF_ENV="$PIPE_CONTEXT_DIR/review-fix.env"
|
|
@@ -87,7 +87,14 @@ pipe_metric_event code-reviewer review_findings \
|
|
|
87
87
|
--argjson inc "$([ "${PIPE_IS_INCREMENTAL:-false}" = "true" ] && echo true || echo false)" \
|
|
88
88
|
'{findings_total: $t, status: $s, has_fixable_bugs: $b, is_incremental: $inc}')"
|
|
89
89
|
|
|
90
|
-
# 7.
|
|
90
|
+
# 7. Fail the job when no review was produced. The comment above already told
|
|
91
|
+
# the humans; this tells the pipeline. PIPE_NO_GATE does not apply: it turns
|
|
92
|
+
# off the verdict gate, and this is a broken run, not a verdict. Without it a
|
|
93
|
+
# review that never ran (crashed agent, missing ANTHROPIC_API_KEY) reads as a
|
|
94
|
+
# review that ran and found nothing, and the MR goes green unreviewed.
|
|
95
|
+
pipe_require_agent_ran "$PIPE_RESULT_REVIEW" "The review agent" || exit 1
|
|
96
|
+
|
|
97
|
+
# 8. Gate. review.env is exported so the fix job can read REVIEW_HAS_BUGS at
|
|
91
98
|
# runtime. On GitLab the job exits non-zero to block the pipeline. On GitHub
|
|
92
99
|
# the workflow step owns the gate (it reads the output first), so it sets
|
|
93
100
|
# PIPE_NO_GATE=1 and this script leaves the exit to the workflow.
|
|
@@ -27,6 +27,9 @@ COVERAGE_BEFORE=$(pipe_count_fix_commits "$PIPE_COMMIT_COVERAGE")
|
|
|
27
27
|
# test-writer, verifies, and writes fix-tests.env. On cap it runs the shared
|
|
28
28
|
# postmortem flow (writing postmortem.md + postmortem.env).
|
|
29
29
|
pipe_run_agent test-fix "/fix-tests" "Agent,Read,Write,Edit,Glob,Grep,Bash" "$PIPE_MODEL_CODE" < /dev/null
|
|
30
|
+
# A crashed agent writes no marker, which step 4 would read as FIX_BRANCH=none
|
|
31
|
+
# and report as a false-alarm trigger, hiding the failure behind a green job.
|
|
32
|
+
pipe_require_agent_ran "" "The test-fix agent" || exit 1
|
|
30
33
|
|
|
31
34
|
# 4. Read the marker the skill wrote.
|
|
32
35
|
FT_ENV="$PIPE_CONTEXT_DIR/fix-tests.env"
|