@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 +11 -6
- package/bin/init.mjs +200 -8
- package/package.json +1 -1
- package/payload/INSTALL.md +23 -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/`.
|