@supersoniks/concorde 4.9.2 → 4.9.4

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.
@@ -1,26 +1,47 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Installe les fichiers agent IA Concorde dans un projet.
3
+ * Install Concorde AI agent files into a project.
4
4
  *
5
5
  * Usage:
6
6
  * node node_modules/@supersoniks/concorde/scripts/ai-init.mjs [options]
7
- * yarn concorde ai-init
7
+ * yarn ai-init
8
8
  *
9
9
  * Options:
10
- * --project-root <dir> Racine du projet (défaut: cwd)
11
- * --concorde-ai <dir> Dossier ai/ Concorde (défaut: auto-detect)
12
- * --overlay <dir> Couche additionnelle (ex. starter kit)
13
- * --cursor-only N'installe que Cursor (.cursor/)
14
- * --jetbrains-only N'installe que JetBrains (.aiassistant/rules/)
15
- * --merge-agents Fusionne AGENTS.md au lieu d'écraser si existant
10
+ * --project-root <dir> Project root (default: cwd)
11
+ * --concorde-ai <dir> Concorde ai/ folder (default: auto-detect)
12
+ * --overlay <dir> Extra layer (e.g. starter kit) — uses AGENTS.starter.md
13
+ * --cursor-only Install only Cursor (.cursor/)
14
+ * --jetbrains-only Install only JetBrains (.aiassistant/rules/)
15
+ * --skip-agents Never write AGENTS.md (skills/rules only)
16
+ * --merge-agents Opt-in: keep project preamble, append/refresh Concorde block
17
+ * --force-agents Opt-in: replace AGENTS.md entirely (destructive)
18
+ *
19
+ * AGENTS.md policy (non-invasive by default):
20
+ * - Missing file → create Concorde guide
21
+ * - Existing with <!-- concorde-ai --> → refresh managed zone only
22
+ * - Existing without marker → leave untouched (project-owned, e.g. billetterie)
23
+ * unless --merge-agents or --force-agents
24
+ *
25
+ * Layout when writing:
26
+ *
27
+ * [optional project preamble — short, project-only]
28
+ * <!-- concorde-ai -->
29
+ * <package ai/AGENTS.md>
30
+ * [--- + overlay AGENTS.starter.md]
31
+ *
32
+ * Everything AFTER the marker is owned by Concorde and replaced on every run.
33
+ * Do not copy the full Concorde guide into the preamble — use overlay/AGENTS.starter.md
34
+ * for project-specific agent notes (endpoints, dpKeys, …).
16
35
  */
17
36
 
18
37
  import fs from "node:fs";
19
38
  import path from "node:path";
20
- import {fileURLToPath} from "node:url";
39
+ import {fileURLToPath, pathToFileURL} from "node:url";
21
40
 
22
41
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
42
 
43
+ const AGENTS_MARKER = "<!-- concorde-ai -->";
44
+
24
45
  const args = process.argv.slice(2);
25
46
 
26
47
  function readArg(name) {
@@ -38,10 +59,34 @@ const overlayDir = readArg("--overlay")
38
59
 
39
60
  const cursorOnly = flags.has("--cursor-only");
40
61
  const jetbrainsOnly = flags.has("--jetbrains-only");
62
+ const skipAgents = flags.has("--skip-agents");
41
63
  const mergeAgents = flags.has("--merge-agents");
64
+ const forceAgents = flags.has("--force-agents");
42
65
  const installCursor = !jetbrainsOnly;
43
66
  const installJetbrains = !cursorOnly;
44
67
 
68
+ /**
69
+ * Decide whether / how to write AGENTS.md.
70
+ * @returns {"skip" | "create" | "refresh" | "merge" | "force"}
71
+ */
72
+ export function decideAgentsAction({
73
+ existingText = "",
74
+ skipAgents: skip = false,
75
+ mergeAgents: merge = false,
76
+ forceAgents: force = false,
77
+ } = {}) {
78
+ if (skip) return "skip";
79
+ const text = existingText.replace(/\r\n/g, "\n");
80
+ const exists = text.trim().length > 0;
81
+ const hasMarker = text.includes(AGENTS_MARKER);
82
+
83
+ if (!exists) return "create";
84
+ if (hasMarker) return "refresh";
85
+ if (force) return "force";
86
+ if (merge) return "merge";
87
+ return "skip";
88
+ }
89
+
45
90
  function detectConcordeAi() {
46
91
  const explicit = readArg("--concorde-ai");
47
92
  if (explicit) return path.resolve(explicit);
@@ -72,96 +117,337 @@ function copyDir(src, dest) {
72
117
  }
73
118
  }
74
119
 
75
- function mergeAgentsFile(targetPath, baseContent, overlayContent) {
76
- const marker = "<!-- concorde-ai -->";
77
- let existing = "";
78
- if (fs.existsSync(targetPath)) {
79
- existing = fs.readFileSync(targetPath, "utf8");
120
+ function normalizeHeading(title) {
121
+ return title
122
+ .normalize("NFKD")
123
+ .replace(/\p{M}/gu, "")
124
+ .toLowerCase()
125
+ .replace(/[^a-z0-9]+/g, " ")
126
+ .trim();
127
+ }
128
+
129
+ function isStockConcordeH1(title) {
130
+ // "# Agents — Concorde" with optional project suffix in parentheses
131
+ return /^agents\s+concorde(\s|$)/i.test(normalizeHeading(title));
132
+ }
133
+
134
+ /**
135
+ * Split markdown into a leading H1 block + ## sections (## title → body).
136
+ * @returns {{ h1: string | null, sections: Array<{ title: string, body: string }> }}
137
+ */
138
+ function splitAgentsSections(markdown) {
139
+ const text = markdown.replace(/\r\n/g, "\n").trim();
140
+ if (!text) return {h1: null, sections: []};
141
+
142
+ const parts = text.split(/\n(?=##\s+)/);
143
+ let h1 = null;
144
+ const sections = [];
145
+
146
+ for (const part of parts) {
147
+ const trimmed = part.trim();
148
+ if (!trimmed) continue;
149
+ if (trimmed.startsWith("## ")) {
150
+ const nl = trimmed.indexOf("\n");
151
+ const titleLine = nl === -1 ? trimmed : trimmed.slice(0, nl);
152
+ const title = titleLine.replace(/^##\s+/, "").trim();
153
+ const body = nl === -1 ? "" : trimmed.slice(nl + 1).trim();
154
+ sections.push({title, body});
155
+ } else if (h1 === null) {
156
+ h1 = trimmed;
157
+ } else {
158
+ // Orphan content without ## — keep as synthetic section
159
+ sections.push({title: "", body: trimmed});
160
+ }
161
+ }
162
+
163
+ return {h1, sections};
164
+ }
165
+
166
+ function baseHeadingSet(baseContent) {
167
+ const {sections} = splitAgentsSections(baseContent);
168
+ return new Set(sections.map((s) => normalizeHeading(s.title)).filter(Boolean));
169
+ }
170
+
171
+ /** Match "## Skills / rules" with "## Skills / rules (après installation)". */
172
+ function isSameAgentsHeading(a, b) {
173
+ if (!a || !b) return false;
174
+ if (a === b) return true;
175
+ const stripParen = (s) => s.replace(/\s*\(.*\)$/, "").trim();
176
+ const aa = stripParen(a);
177
+ const bb = stripParen(b);
178
+ if (aa === bb) return true;
179
+ return aa.startsWith(bb) || bb.startsWith(aa);
180
+ }
181
+
182
+ function isPackageHeading(titleKey, baseHeads) {
183
+ for (const bh of baseHeads) {
184
+ if (isSameAgentsHeading(titleKey, bh)) return true;
185
+ }
186
+ return false;
187
+ }
188
+
189
+ /**
190
+ * True when preamble looks like a full (or near-full) copy of package AGENTS.md.
191
+ */
192
+ export function preambleDuplicatesBase(preamble, baseContent) {
193
+ const p = preamble.trim();
194
+ const b = baseContent.trim();
195
+ if (!p || !b) return false;
196
+
197
+ const fingerprints = [
198
+ "sonic-fetch",
199
+ "publishermanager",
200
+ "formdataprovider",
201
+ "dataprovide",
202
+ "concorde-get-set-dp",
203
+ ];
204
+ const pNorm = p.toLowerCase();
205
+ const hits = fingerprints.filter((fp) => pNorm.includes(fp)).length;
206
+ const longEnough = p.length >= b.length * 0.45;
207
+ const stockTitle = /^#\s*agents\s*[—–-]\s*concorde\b/im.test(p);
208
+
209
+ return stockTitle && longEnough && hits >= 3;
210
+ }
211
+
212
+ /**
213
+ * Keep only project-specific preamble bits (unique ## vs package, short custom H1).
214
+ * Drops sections that duplicate Concorde ai/AGENTS.md so re-runs heal stacked copies.
215
+ */
216
+ export function slimPreamble(preamble, baseContent) {
217
+ const raw = preamble.trim();
218
+ if (!raw) return "";
219
+
220
+ const {h1, sections} = splitAgentsSections(raw);
221
+ const h1Title = h1?.split("\n")[0]?.replace(/^#\s+/, "").trim() ?? "";
222
+ // Full forks OR any preamble titled like Concorde AGENTS → strip package ## sections
223
+ const shouldDedup =
224
+ preambleDuplicatesBase(raw, baseContent) || isStockConcordeH1(h1Title);
225
+
226
+ if (!shouldDedup) {
227
+ return raw;
228
+ }
229
+
230
+ const baseHeads = baseHeadingSet(baseContent);
231
+ const kept = [];
232
+
233
+ for (const section of sections) {
234
+ const key = normalizeHeading(section.title);
235
+ if (!key) {
236
+ if (!preambleDuplicatesBase(section.body, baseContent)) {
237
+ kept.push(section.body);
238
+ }
239
+ continue;
240
+ }
241
+ if (!isPackageHeading(key, baseHeads)) {
242
+ kept.push(`## ${section.title}\n\n${section.body}`.trim());
243
+ }
244
+ }
245
+
246
+ let intro = "";
247
+ if (h1) {
248
+ const firstLine = h1.split("\n")[0].trim();
249
+ const title = firstLine.replace(/^#\s+/, "");
250
+ if (isStockConcordeH1(title) && /[()]/.test(title)) {
251
+ intro = firstLine;
252
+ } else if (!isStockConcordeH1(title)) {
253
+ intro = firstLine;
254
+ }
80
255
  }
81
256
 
257
+ return [intro, ...kept].filter(Boolean).join("\n\n").trim();
258
+ }
259
+
260
+ export function buildManagedAgents(baseContent, overlayContent) {
82
261
  const parts = [baseContent.trim()];
83
262
  if (overlayContent?.trim()) {
84
263
  parts.push(overlayContent.trim());
85
264
  }
265
+ return parts.join("\n\n---\n\n");
266
+ }
86
267
 
87
- const combined = parts.join("\n\n---\n\n");
268
+ /**
269
+ * Build final AGENTS.md. Never stacks managed (post-marker) content.
270
+ * @returns {{ markdown: string, warnings: string[] }}
271
+ */
272
+ export function buildAgentsFile({
273
+ existing = "",
274
+ baseContent,
275
+ overlayContent = "",
276
+ mergeAgents: merge = false,
277
+ forceAgents: force = false,
278
+ }) {
279
+ const warnings = [];
280
+ const combined = buildManagedAgents(baseContent, overlayContent);
88
281
 
89
- if (!existing) {
90
- fs.mkdirSync(path.dirname(targetPath), {recursive: true});
91
- fs.writeFileSync(targetPath, combined + "\n");
92
- return;
282
+ // Destructive replace: ignore any existing project content
283
+ if (force && !existing.replace(/\r\n/g, "\n").includes(AGENTS_MARKER)) {
284
+ return {markdown: `${combined}\n`, warnings};
93
285
  }
94
286
 
95
- if (existing.includes(marker)) {
96
- const before = existing.split(marker)[0].trimEnd();
97
- fs.writeFileSync(targetPath, `${before}\n\n${marker}\n\n${combined}\n`);
98
- return;
287
+ let preamble = "";
288
+ const existingText = existing.replace(/\r\n/g, "\n");
289
+
290
+ if (existingText.includes(AGENTS_MARKER)) {
291
+ preamble = existingText.split(AGENTS_MARKER)[0].trimEnd();
292
+ } else if (merge && existingText.trim()) {
293
+ preamble = existingText.trimEnd();
99
294
  }
100
295
 
101
- if (mergeAgents) {
102
- fs.writeFileSync(
103
- targetPath,
104
- `${existing.trimEnd()}\n\n---\n\n${marker}\n\n${combined}\n`,
105
- );
106
- return;
296
+ const hadMarker = existingText.includes(AGENTS_MARKER);
297
+
298
+ if (preamble) {
299
+ const before = preamble;
300
+ preamble = slimPreamble(preamble, baseContent);
301
+ if (before.trim() && !preamble) {
302
+ warnings.push(
303
+ "AGENTS.md preamble duplicated Concorde ai/AGENTS.md — dropped. Put project-only notes in overlay AGENTS.starter.md (or a short preamble before <!-- concorde-ai -->).",
304
+ );
305
+ } else if (before.trim() && preamble !== before.trim()) {
306
+ warnings.push(
307
+ "AGENTS.md preamble slimmed: removed sections that duplicate package AGENTS.md.",
308
+ );
309
+ }
107
310
  }
108
311
 
109
- fs.writeFileSync(targetPath, combined + "\n");
312
+ // Always emit the marker when refreshing a marked file or merging a preamble,
313
+ // so re-runs stay idempotent even if the preamble was fully dropped.
314
+ const emitMarker = hadMarker || Boolean(preamble) || merge;
315
+ const markdown = emitMarker
316
+ ? preamble
317
+ ? `${preamble.trimEnd()}\n\n${AGENTS_MARKER}\n\n${combined}\n`
318
+ : `${AGENTS_MARKER}\n\n${combined}\n`
319
+ : `${combined}\n`;
320
+
321
+ return {markdown, warnings};
110
322
  }
111
323
 
112
- const concordeAi = detectConcordeAi();
324
+ export function mergeAgentsFile(targetPath, baseContent, overlayContent, options = {}) {
325
+ const merge = options.mergeAgents ?? false;
326
+ const force = options.forceAgents ?? false;
327
+ let existing = "";
328
+ if (fs.existsSync(targetPath)) {
329
+ existing = fs.readFileSync(targetPath, "utf8");
330
+ }
113
331
 
114
- console.log(`Concorde ai-init → ${projectRoot}`);
115
- console.log(` source: ${concordeAi}`);
116
- if (overlayDir) console.log(` overlay: ${overlayDir}`);
332
+ const {markdown, warnings} = buildAgentsFile({
333
+ existing,
334
+ baseContent,
335
+ overlayContent,
336
+ mergeAgents: merge,
337
+ forceAgents: force,
338
+ });
117
339
 
118
- if (installCursor) {
119
- copyDir(
120
- path.join(concordeAi, "cursor", "rules"),
121
- path.join(projectRoot, ".cursor", "rules"),
122
- );
123
- copyDir(
124
- path.join(concordeAi, "skills"),
125
- path.join(projectRoot, ".cursor", "skills"),
126
- );
340
+ fs.mkdirSync(path.dirname(targetPath), {recursive: true});
341
+ fs.writeFileSync(targetPath, markdown);
342
+ return warnings;
343
+ }
344
+
345
+ function main() {
346
+ const concordeAi = detectConcordeAi();
347
+
348
+ console.log(`Concorde ai-init → ${projectRoot}`);
349
+ console.log(` source: ${concordeAi}`);
350
+ if (overlayDir) console.log(` overlay: ${overlayDir}`);
127
351
 
128
- if (overlayDir) {
352
+ if (installCursor) {
129
353
  copyDir(
130
- path.join(overlayDir, "cursor", "rules"),
354
+ path.join(concordeAi, "cursor", "rules"),
131
355
  path.join(projectRoot, ".cursor", "rules"),
132
356
  );
133
357
  copyDir(
134
- path.join(overlayDir, "skills"),
358
+ path.join(concordeAi, "skills"),
135
359
  path.join(projectRoot, ".cursor", "skills"),
136
360
  );
137
- }
138
- }
139
361
 
140
- if (installJetbrains) {
141
- copyDir(
142
- path.join(concordeAi, "jetbrains", "rules"),
143
- path.join(projectRoot, ".aiassistant", "rules"),
144
- );
362
+ if (overlayDir) {
363
+ copyDir(
364
+ path.join(overlayDir, "cursor", "rules"),
365
+ path.join(projectRoot, ".cursor", "rules"),
366
+ );
367
+ copyDir(
368
+ path.join(overlayDir, "skills"),
369
+ path.join(projectRoot, ".cursor", "skills"),
370
+ );
371
+ }
372
+ }
145
373
 
146
- if (overlayDir) {
374
+ if (installJetbrains) {
147
375
  copyDir(
148
- path.join(overlayDir, "jetbrains", "rules"),
376
+ path.join(concordeAi, "jetbrains", "rules"),
149
377
  path.join(projectRoot, ".aiassistant", "rules"),
150
378
  );
379
+
380
+ if (overlayDir) {
381
+ copyDir(
382
+ path.join(overlayDir, "jetbrains", "rules"),
383
+ path.join(projectRoot, ".aiassistant", "rules"),
384
+ );
385
+ }
151
386
  }
152
- }
153
387
 
154
- const baseAgents = fs.readFileSync(path.join(concordeAi, "AGENTS.md"), "utf8");
155
- let overlayAgents = "";
156
- if (overlayDir) {
157
- const overlayPath = path.join(overlayDir, "AGENTS.starter.md");
158
- if (fs.existsSync(overlayPath)) {
159
- overlayAgents = fs.readFileSync(overlayPath, "utf8");
388
+ const agentsPath = path.join(projectRoot, "AGENTS.md");
389
+ let existingAgents = "";
390
+ if (fs.existsSync(agentsPath)) {
391
+ existingAgents = fs.readFileSync(agentsPath, "utf8");
160
392
  }
393
+
394
+ const action = decideAgentsAction({
395
+ existingText: existingAgents,
396
+ skipAgents,
397
+ mergeAgents,
398
+ forceAgents,
399
+ });
400
+
401
+ if (action === "skip") {
402
+ if (skipAgents) {
403
+ console.log(" ⊘ AGENTS.md skipped (--skip-agents)");
404
+ } else if (existingAgents.trim()) {
405
+ console.log(
406
+ " ⊘ AGENTS.md left untouched (project-owned, no <!-- concorde-ai --> marker).",
407
+ );
408
+ console.log(
409
+ " Use --merge-agents to append a Concorde block, or --force-agents to replace.",
410
+ );
411
+ } else {
412
+ console.log(" ⊘ AGENTS.md skipped");
413
+ }
414
+ } else {
415
+ const baseAgents = fs.readFileSync(path.join(concordeAi, "AGENTS.md"), "utf8");
416
+ let overlayAgents = "";
417
+ if (overlayDir) {
418
+ const overlayPath = path.join(overlayDir, "AGENTS.starter.md");
419
+ if (fs.existsSync(overlayPath)) {
420
+ overlayAgents = fs.readFileSync(overlayPath, "utf8");
421
+ }
422
+ }
423
+
424
+ const warnings = mergeAgentsFile(agentsPath, baseAgents, overlayAgents, {
425
+ mergeAgents: action === "merge" || action === "refresh",
426
+ forceAgents: action === "force",
427
+ });
428
+ for (const w of warnings) {
429
+ console.warn(` ⚠ ${w}`);
430
+ }
431
+ const label =
432
+ action === "create"
433
+ ? "created"
434
+ : action === "merge"
435
+ ? "merged"
436
+ : action === "force"
437
+ ? "replaced (--force-agents)"
438
+ : "refreshed";
439
+ console.log(` ✓ AGENTS.md (${label})`);
440
+ }
441
+
442
+ if (installCursor) console.log(" ✓ .cursor/skills + .cursor/rules");
443
+ if (installJetbrains) console.log(" ✓ .aiassistant/rules");
444
+ console.log("Done.");
161
445
  }
162
- mergeAgentsFile(path.join(projectRoot, "AGENTS.md"), baseAgents, overlayAgents);
163
446
 
164
- console.log(" ✓ AGENTS.md");
165
- if (installCursor) console.log(" ✓ .cursor/skills + .cursor/rules");
166
- if (installJetbrains) console.log(" ✓ .aiassistant/rules");
167
- console.log("Done.");
447
+ const isDirectRun =
448
+ process.argv[1] &&
449
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
450
+
451
+ if (isDirectRun) {
452
+ main();
453
+ }
@@ -0,0 +1,206 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import {
4
+ buildAgentsFile,
5
+ decideAgentsAction,
6
+ preambleDuplicatesBase,
7
+ slimPreamble,
8
+ } from "./ai-init.mjs";
9
+
10
+ const base = `# Agents — Concorde
11
+
12
+ Guide for Concorde.
13
+
14
+ ## Skills / rules
15
+
16
+ | File | Role |
17
+ |------|------|
18
+ | \`.cursor/skills/concorde/SKILL.md\` | Patterns |
19
+
20
+ ## Conventions impératives
21
+
22
+ - Always **DataProvider**
23
+ - No **sonic-fetch**, no **PublisherManager**
24
+ - Forms: **formDataProvider**
25
+ - Skill **concorde-get-set-dp**
26
+ `;
27
+
28
+ const overlay = `## Conventions (project)
29
+
30
+ - HTTP routes: \`endpoints.*\`
31
+ `;
32
+
33
+ test("decideAgentsAction: missing file → create", () => {
34
+ assert.equal(decideAgentsAction({existingText: ""}), "create");
35
+ });
36
+
37
+ test("decideAgentsAction: project-owned AGENTS.md → skip by default", () => {
38
+ assert.equal(
39
+ decideAgentsAction({existingText: "# Billetterie\n\nProject notes.\n"}),
40
+ "skip",
41
+ );
42
+ });
43
+
44
+ test("decideAgentsAction: --skip-agents always skips", () => {
45
+ assert.equal(
46
+ decideAgentsAction({existingText: "", skipAgents: true}),
47
+ "skip",
48
+ );
49
+ });
50
+
51
+ test("decideAgentsAction: --merge-agents on project-owned → merge", () => {
52
+ assert.equal(
53
+ decideAgentsAction({
54
+ existingText: "# Billetterie\n\nNotes.\n",
55
+ mergeAgents: true,
56
+ }),
57
+ "merge",
58
+ );
59
+ });
60
+
61
+ test("decideAgentsAction: --force-agents on project-owned → force", () => {
62
+ assert.equal(
63
+ decideAgentsAction({
64
+ existingText: "# Billetterie\n\nNotes.\n",
65
+ forceAgents: true,
66
+ }),
67
+ "force",
68
+ );
69
+ });
70
+
71
+ test("decideAgentsAction: marked file → refresh", () => {
72
+ assert.equal(
73
+ decideAgentsAction({
74
+ existingText: "# App\n\n<!-- concorde-ai -->\n\nold\n",
75
+ }),
76
+ "refresh",
77
+ );
78
+ });
79
+
80
+ test("forceAgents replaces project-owned content", () => {
81
+ const {markdown} = buildAgentsFile({
82
+ existing: "# Old project notes\n\nkeep me",
83
+ baseContent: base,
84
+ overlayContent: "",
85
+ forceAgents: true,
86
+ });
87
+ assert.equal(markdown.includes("keep me"), false);
88
+ assert.equal(markdown.includes("<!-- concorde-ai -->"), false);
89
+ assert.match(markdown, /^# Agents — Concorde/m);
90
+ });
91
+
92
+ test("without force/merge, buildAgentsFile still builds content (caller decides skip)", () => {
93
+ const {markdown} = buildAgentsFile({
94
+ existing: "",
95
+ baseContent: base,
96
+ overlayContent: "",
97
+ });
98
+ assert.match(markdown, /^# Agents — Concorde/m);
99
+ });
100
+
101
+ test("merge without marker keeps short unique preamble", () => {
102
+ const {markdown, warnings} = buildAgentsFile({
103
+ existing: "# My app\n\nOnly project notes here.\n",
104
+ baseContent: base,
105
+ overlayContent: "",
106
+ mergeAgents: true,
107
+ });
108
+ assert.match(markdown, /# My app/);
109
+ assert.match(markdown, /<!-- concorde-ai -->/);
110
+ assert.match(markdown, /# Agents — Concorde/);
111
+ assert.equal(warnings.length, 0);
112
+ });
113
+
114
+ test("re-run with marker replaces managed zone only (idempotent)", () => {
115
+ const first = buildAgentsFile({
116
+ existing: "# My app\n\nNote.\n",
117
+ baseContent: base,
118
+ overlayContent: "v1 overlay",
119
+ mergeAgents: true,
120
+ }).markdown;
121
+
122
+ const second = buildAgentsFile({
123
+ existing: first.replace("v1 overlay", "STALE"),
124
+ baseContent: base,
125
+ overlayContent: "v2 overlay",
126
+ mergeAgents: true,
127
+ }).markdown;
128
+
129
+ assert.equal(second.includes("STALE"), false);
130
+ assert.match(second, /v2 overlay/);
131
+ assert.equal(second.split("<!-- concorde-ai -->").length, 2);
132
+ });
133
+
134
+ test("duplicate Concorde preamble is dropped", () => {
135
+ const fork = `${base}\n\nExtra copy of the same guide.\n`;
136
+ assert.equal(preambleDuplicatesBase(fork, base), true);
137
+
138
+ const {markdown, warnings} = buildAgentsFile({
139
+ existing: `${fork}\n\n<!-- concorde-ai -->\n\nold managed\n`,
140
+ baseContent: base,
141
+ overlayContent: overlay,
142
+ mergeAgents: true,
143
+ });
144
+
145
+ assert.equal(markdown.split("<!-- concorde-ai -->").length, 2);
146
+ assert.equal(
147
+ markdown.indexOf("# Agents — Concorde"),
148
+ markdown.lastIndexOf("# Agents — Concorde"),
149
+ );
150
+ assert.match(markdown, /Conventions \(project\)/);
151
+ assert.ok(warnings.some((w) => /duplicated|slimmed/i.test(w)));
152
+ });
153
+
154
+ test("slimPreamble keeps unique ## sections", () => {
155
+ const preamble = `# Agents — Concorde (my-app)
156
+
157
+ Guide.
158
+
159
+ ## Skills / rules
160
+
161
+ dup
162
+
163
+ ## Project only
164
+
165
+ - use endpoints.ts
166
+ `;
167
+ const slim = slimPreamble(preamble, base);
168
+ assert.match(slim, /Project only/);
169
+ assert.equal(slim.includes("## Skills / rules"), false);
170
+ assert.match(slim, /Concorde \(my-app\)/);
171
+ });
172
+
173
+ test("slimPreamble drops Skills even when package title has suffix", () => {
174
+ const packageBase = `# Agents — Concorde
175
+
176
+ ## Skills / rules (après installation)
177
+
178
+ table
179
+
180
+ ## Conventions impératives
181
+
182
+ - sonic-fetch
183
+ - PublisherManager
184
+ - formDataProvider
185
+ - concorde-get-set-dp
186
+ `;
187
+ const preamble = `# Agents — Concorde (app)
188
+
189
+ ## Skills / rules
190
+
191
+ dup table
192
+
193
+ ## Installation / réinstallation
194
+
195
+ custom install cmd
196
+
197
+ ## Project endpoints
198
+
199
+ - endpoints.ts
200
+ `;
201
+ const slim = slimPreamble(preamble, packageBase);
202
+ assert.equal(slim.includes("## Skills / rules"), false);
203
+ assert.match(slim, /Project endpoints/);
204
+ // Installation may stay if not in package — package has no Installation ##
205
+ assert.match(slim, /Installation \/ réinstallation|Project endpoints/);
206
+ });