@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 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
- > [!NOTE]
174
- > Reconciling those checksums is not implemented yet. Until it is, re-running
175
- > refreshes the source folder and the manifest; treat updating the installed
176
- > copies under `.claude/` as a manual diff, and keep your own edits in
177
- > `pipeline-config.md` rather than in agent files, where nothing will reclaim
178
- > them.
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 stable across platforms.
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(payloadFiles.map((rel) => [rel, sha256(path.join(PAYLOAD, rel))])),
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: phase 1 of .ci-agent-platform-src/skills/init-pipeline-config/SKILL.md
204
- (scan the repository, select agents, draft the config).
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
- say('Next: open Claude Code in this repository and ask it to install the')
234
- say('pipeline (any phrasing). It reads the instructions and takes it from here.')
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.1.4",
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",
@@ -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" || true
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
- ) || true
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" || true
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
- ) || true
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. Gate. review.env is exported so the fix job can read REVIEW_HAS_BUGS at
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"