@cxi-lmai/ci-agent-platform 3.1.5 → 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.5",
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/`.