basalt-ui 1.40.3 → 1.41.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/dist/cli/index.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  readFileSync,
8
8
  rmdirSync,
9
9
  statSync,
10
+ realpathSync,
10
11
  unlinkSync,
11
12
  writeFileSync
12
13
  } from "node:fs";
@@ -233,6 +234,8 @@ function waiverHintFor(relPath, profile) {
233
234
  const BLOCK_BEGIN_PREFIX = "<!-- basalt:begin";
234
235
  const BLOCK_END = "<!-- basalt:end -->";
235
236
  const MANIFEST_PATH = ".basalt/manifest.json";
237
+ const AGENTS_MD = "AGENTS.md";
238
+ const LEGACY_BLOCK_HOST = "CLAUDE.md";
236
239
  function sha256(text) {
237
240
  return createHash("sha256").update(text, "utf8").digest("hex");
238
241
  }
@@ -308,6 +311,91 @@ function applyBlock(host, block) {
308
311
  ${block.trim()}
309
312
  `;
310
313
  }
314
+ function sameFile(a, b) {
315
+ try {
316
+ return realpathSync(a) === realpathSync(b);
317
+ } catch (error) {
318
+ if (error.code === "ENOENT") return false;
319
+ throw error;
320
+ }
321
+ }
322
+ function hasManagedBlock(host) {
323
+ return host !== null && host.includes(BLOCK_BEGIN_PREFIX);
324
+ }
325
+ function scanBlock(host) {
326
+ const begin = hasManagedBlock(host);
327
+ if (!begin && !host.includes(BLOCK_END)) return { region: null, problem: null };
328
+ const bad = (problem) => ({ region: null, problem });
329
+ if (!begin) return bad(`\`${BLOCK_END}\` with no \`${BLOCK_BEGIN_PREFIX}\``);
330
+ let region;
331
+ try {
332
+ region = findBlockRegion(host);
333
+ } catch (error) {
334
+ return bad(error.message);
335
+ }
336
+ if (region === null) return bad(`\`${BLOCK_BEGIN_PREFIX}\` has no matching \`${BLOCK_END}\``);
337
+ if (host.indexOf(BLOCK_END) < region.start || host.indexOf(BLOCK_END, region.end) !== -1) {
338
+ return bad(`an extra or misplaced \`${BLOCK_END}\` outside the basalt block`);
339
+ }
340
+ return { region, problem: null };
341
+ }
342
+ function isAgentsImportLine(line) {
343
+ return line.trim() === `@${AGENTS_MD}`;
344
+ }
345
+ function importsAgentsMd(text) {
346
+ return text.split(/\r?\n/).some(isAgentsImportLine);
347
+ }
348
+ function migrateBlockToAgentsMd(cwd, manifest, opts = {}) {
349
+ const legacyHash = manifest.files[LEGACY_BLOCK_HOST];
350
+ if (legacyHash !== void 0) {
351
+ manifest.files[AGENTS_MD] ??= legacyHash;
352
+ delete manifest.files[LEGACY_BLOCK_HOST];
353
+ }
354
+ const none = { pending: false, deletedHost: false, problem: null };
355
+ const legacyAbs = resolve(cwd, LEGACY_BLOCK_HOST);
356
+ const agentsAbs = resolve(cwd, AGENTS_MD);
357
+ const legacy = readIfExists(legacyAbs);
358
+ if (legacy === null) return none;
359
+ try {
360
+ if (sameFile(legacyAbs, agentsAbs)) return none;
361
+ } catch (error) {
362
+ const why = error.code ?? error.message;
363
+ return { ...none, problem: `cannot tell whether ${LEGACY_BLOCK_HOST} is ${AGENTS_MD} (${why})` };
364
+ }
365
+ const legacyScan = scanBlock(legacy);
366
+ const { region } = legacyScan;
367
+ if (legacyScan.problem !== null) {
368
+ return { ...none, problem: `${LEGACY_BLOCK_HOST}: ${legacyScan.problem}` };
369
+ }
370
+ if (region === null) return none;
371
+ const agents = readIfExists(agentsAbs) ?? "";
372
+ const agentsScan = scanBlock(agents);
373
+ if (agentsScan.problem !== null)
374
+ return { ...none, problem: `${AGENTS_MD}: ${agentsScan.problem}` };
375
+ const legacyBlock = legacy.slice(region.start, region.end);
376
+ if (agentsScan.region !== null && normalizeForLedger(agents.slice(agentsScan.region.start, agentsScan.region.end)) !== normalizeForLedger(legacyBlock)) {
377
+ return {
378
+ ...none,
379
+ problem: `${LEGACY_BLOCK_HOST} and ${AGENTS_MD} each carry a basalt block and they differ \u2014 delete the stale block from ${LEGACY_BLOCK_HOST} (or the one in ${AGENTS_MD}, if ${LEGACY_BLOCK_HOST}'s is the one you want kept), then re-run.`
380
+ };
381
+ }
382
+ const before = legacy.slice(0, region.start).trimEnd();
383
+ const after = legacy.slice(region.end).trimStart();
384
+ const remainder = before === "" || after === "" ? `${before}${after}` : `${before}
385
+
386
+ ${after}`;
387
+ const deletedHost = remainder.split(/\r?\n/).every((l) => l.trim() === "" || isAgentsImportLine(l));
388
+ if (opts.dryRun === true) return { pending: true, deletedHost, problem: null };
389
+ if (agentsScan.region === null) writeFileEnsuringDir(agentsAbs, applyBlock(agents, legacyBlock));
390
+ if (deletedHost) unlinkSync(legacyAbs);
391
+ else writeFileSync(legacyAbs, `${remainder.trimEnd()}
392
+ `);
393
+ return { pending: true, deletedHost, problem: null };
394
+ }
395
+ function blockMigrationProblem(cwd) {
396
+ const { problem } = migrateBlockToAgentsMd(cwd, { version: 1, files: {} }, { dryRun: true });
397
+ return problem === null ? null : `${problem.replace(/\.$/, "")}. Nothing else was reconciled \u2014 resolve this by hand, then re-run.`;
398
+ }
311
399
  function hasDependency(cwd, pkgName) {
312
400
  try {
313
401
  const pkg = JSON.parse(readFileSync(resolve(cwd, "package.json"), "utf8"));
@@ -397,15 +485,15 @@ function managedFiles(peers, placement = ROOT_PLACEMENT) {
397
485
  source: `agent/skills/${name}/SKILL.md`,
398
486
  render: (ctx) => readSource(ctx.pkgRoot, `agent/skills/${name}/SKILL.md`)
399
487
  }));
400
- const claudeBlock = {
401
- dest: "CLAUDE.md",
488
+ const agentsBlock = {
489
+ dest: AGENTS_MD,
402
490
  mode: "managed",
403
491
  markers: true,
404
- source: "agent/templates/CLAUDE-block.md.tpl",
492
+ source: "agent/templates/AGENTS-block.md.tpl",
405
493
  // With markers, render() returns the fully-rendered region INCLUDING its begin/end markers.
406
- // The writer splices it into the consumer's host CLAUDE.md at apply time.
494
+ // The writer splices it into the consumer's host AGENTS.md at apply time.
407
495
  render: (ctx) => {
408
- const tpl = readSource(ctx.pkgRoot, "agent/templates/CLAUDE-block.md.tpl");
496
+ const tpl = readSource(ctx.pkgRoot, "agent/templates/AGENTS-block.md.tpl");
409
497
  if (tpl === null) return null;
410
498
  return fillTemplate(tpl, ctx.vars).trim();
411
499
  }
@@ -498,7 +586,7 @@ pre-commit:
498
586
  if (peers.hasQuery && placement.relocatedQueryClient === null) scaffolds.push(queryClient);
499
587
  if (peers.hasRouter && peers.hasQuery) scaffolds.push(rootRoute);
500
588
  const rootTooling = placement.isPackageRepoRoot ? [lefthook, ci] : [];
501
- return [...rules, ...skills, claudeBlock, design, oxfmt, ...rootTooling, oxlintrc, ...scaffolds];
589
+ return [...rules, ...skills, agentsBlock, design, oxfmt, ...rootTooling, oxlintrc, ...scaffolds];
502
590
  }
503
591
  function placementNotices(cwd, placement) {
504
592
  const out = [];
@@ -558,6 +646,14 @@ Retired but locally edited, so left in place \u2014 your agent still loads them.
558
646
  ` + pruned.drifted.map((dest) => ` \xB7 ${dest}`).join("\n")
559
647
  );
560
648
  }
649
+ function reportBlockMigration(migration, command) {
650
+ if (!migration.pending) return;
651
+ const tail = migration.deletedHost ? `${LEGACY_BLOCK_HOST} held nothing else and was deleted.` : `the rest of ${LEGACY_BLOCK_HOST} is yours; while it exists Claude Code loads it INSTEAD of ${AGENTS_MD} unless it imports @${AGENTS_MD} \u2014 move your content over and delete it.`;
652
+ console.log(
653
+ `
654
+ basalt-ui ${command}: moved the basalt block from ${LEGACY_BLOCK_HOST} to ${AGENTS_MD} \u2014 ${tail}`
655
+ );
656
+ }
561
657
  function pruneRetiredManagedFiles(cwd, manifest, files, opts = {}) {
562
658
  const result = { removed: [], drifted: [] };
563
659
  for (const dest of retiredManagedDests(manifest, files)) {
@@ -944,6 +1040,8 @@ function run(argv, cwd = process.cwd()) {
944
1040
  }
945
1041
  }
946
1042
  export {
1043
+ AGENTS_MD,
1044
+ LEGACY_BLOCK_HOST,
947
1045
  MANIFEST_PATH,
948
1046
  OXLINT_FILES,
949
1047
  OXLINT_NO_BIN,
@@ -954,6 +1052,7 @@ export {
954
1052
  SKIP,
955
1053
  basaltBinCommand,
956
1054
  basaltPresetEntry,
1055
+ blockMigrationProblem,
957
1056
  checkTheme,
958
1057
  classify,
959
1058
  conflictingProfileFlags,
@@ -966,11 +1065,15 @@ export {
966
1065
  fontsCss,
967
1066
  guardHook,
968
1067
  hasBasaltKey,
1068
+ hasManagedBlock,
1069
+ importsAgentsMd,
969
1070
  init,
970
1071
  inspectLefthookGate,
1072
+ isAgentsImportLine,
971
1073
  isStringArray,
972
1074
  managedFiles,
973
1075
  mergeOxlintExtends,
1076
+ migrateBlockToAgentsMd,
974
1077
  namesShipped,
975
1078
  normalizeColorFunctions,
976
1079
  normalizeForLedger,
@@ -989,6 +1092,7 @@ export {
989
1092
  reconcileRoots,
990
1093
  relativePosix,
991
1094
  renderContext,
1095
+ reportBlockMigration,
992
1096
  reportPrune,
993
1097
  resolveGuardConfig,
994
1098
  resolvePeerFlags,
@@ -997,6 +1101,8 @@ export {
997
1101
  retiredManagedDests,
998
1102
  rootsNotices,
999
1103
  run,
1104
+ sameFile,
1105
+ scanBlock,
1000
1106
  scannableFiles,
1001
1107
  sha256,
1002
1108
  shippedAssetPath,
@@ -1 +1 @@
1
- {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/cli/init.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAmB,aAAa,EAAE,MAAM,SAAS,CAAA;AA2C7D,yGAAyG;AACzG,wBAAgB,IAAI,CAAC,GAAG,GAAE,MAAsB,EAAE,aAAa,GAAE,aAAkB,GAAG,MAAM,CA4K3F"}
1
+ {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/cli/init.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAmB,aAAa,EAAE,MAAM,SAAS,CAAA;AA8C7D,yGAAyG;AACzG,wBAAgB,IAAI,CAAC,GAAG,GAAE,MAAsB,EAAE,aAAa,GAAE,aAAkB,GAAG,MAAM,CAuL3F"}
package/dist/cli/init.js CHANGED
@@ -3,9 +3,11 @@ import { resolve } from "node:path";
3
3
  import { deriveSpacing } from "../tokens/index.js";
4
4
  import {
5
5
  MANIFEST_PATH,
6
+ blockMigrationProblem,
6
7
  findBasaltInstall,
7
8
  managedFiles,
8
9
  mergeOxlintExtends,
10
+ migrateBlockToAgentsMd,
9
11
  normalizeForLedger,
10
12
  patchPackageJson,
11
13
  placementNotices,
@@ -13,6 +15,7 @@ import {
13
15
  readManifest,
14
16
  reconcileRoots,
15
17
  renderContext,
18
+ reportBlockMigration,
16
19
  reportPrune,
17
20
  resolvePeerFlags,
18
21
  resolvePlacement,
@@ -31,6 +34,11 @@ const KEPT_FILE_COST = {
31
34
  "DESIGN.md": "the design brief Claude reads is yours, not the seeded one."
32
35
  };
33
36
  function init(cwd = process.cwd(), scaffoldFlags = {}) {
37
+ const blockProblem = blockMigrationProblem(cwd);
38
+ if (blockProblem !== null) {
39
+ console.error(`\u2716 basalt-ui init: ${blockProblem}`);
40
+ return 1;
41
+ }
34
42
  const install = findBasaltInstall(cwd);
35
43
  if (install.dir === null) {
36
44
  console.error(
@@ -50,6 +58,7 @@ function init(cwd = process.cwd(), scaffoldFlags = {}) {
50
58
  const peers = resolvePeerFlags(cwd, scaffoldFlags);
51
59
  const placement = resolvePlacement(cwd);
52
60
  const files = managedFiles(peers, placement);
61
+ const migration = migrateBlockToAgentsMd(cwd, manifest);
53
62
  const writtenFiles = [];
54
63
  const keptFiles = [];
55
64
  const missingSources = [];
@@ -79,6 +88,7 @@ function init(cwd = process.cwd(), scaffoldFlags = {}) {
79
88
  `basalt-ui init: ${writtenFiles.length} written, ${keptFiles.length} kept, manifest at ${MANIFEST_PATH}`
80
89
  );
81
90
  reportPrune(pruned);
91
+ reportBlockMigration(migration, "init");
82
92
  if (missingSources.length > 0) {
83
93
  console.log(
84
94
  `basalt-ui init: ${missingSources.length} shipped asset(s) not present, skipped: ${missingSources.join(", ")}`
@@ -1 +1 @@
1
- {"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/cli/sync.ts"],"names":[],"mappings":"AA8CA,gGAAgG;AAChG,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAGjE;AAED,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,mGAAmG;IACnG,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC1B,CAAA;AAED;;;;;;;;;GASG;AACH;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAKrF;AAED,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQ5D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,IAAI,CAAC,IAAI,GAAE,WAAgB,EAAE,aAAa,GAAE,MAAsB,GAAG,MAAM,CAoM1F"}
1
+ {"version":3,"file":"sync.d.ts","sourceRoot":"","sources":["../../src/cli/sync.ts"],"names":[],"mappings":"AAmDA,gGAAgG;AAChG,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAGjE;AAED,MAAM,MAAM,WAAW,GAAG;IACxB,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,mGAAmG;IACnG,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC1B,CAAA;AAED;;;;;;;;;GASG;AACH;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAKrF;AAED,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQ5D;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,IAAI,CAAC,IAAI,GAAE,WAAgB,EAAE,aAAa,GAAE,MAAsB,GAAG,MAAM,CAsN1F"}
package/dist/cli/sync.js CHANGED
@@ -10,8 +10,12 @@ import {
10
10
  diffSummary,
11
11
  findBasaltInstall,
12
12
  inspectLefthookGate,
13
+ AGENTS_MD,
14
+ blockMigrationProblem,
13
15
  isStringArray,
16
+ LEGACY_BLOCK_HOST,
14
17
  managedFiles,
18
+ migrateBlockToAgentsMd,
15
19
  normalizeForLedger,
16
20
  noteRelocation,
17
21
  OXLINT_FILES,
@@ -25,6 +29,7 @@ import {
25
29
  reconcileRoots,
26
30
  relativePosix,
27
31
  renderContext,
32
+ reportBlockMigration,
28
33
  reportPrune,
29
34
  resolvePeerFlags,
30
35
  resolvePlacement,
@@ -72,12 +77,18 @@ function sync(opts = {}, invocationCwd = process.cwd()) {
72
77
  );
73
78
  return 1;
74
79
  }
80
+ const blockProblem = blockMigrationProblem(cwd);
81
+ if (blockProblem !== null) {
82
+ console.error(`\u2716 basalt-ui sync: ${blockProblem}`);
83
+ return 1;
84
+ }
75
85
  const rootsState = reconcileRoots(cwd, { write: opts.check !== true });
76
86
  const ctx = renderContext(cwd);
77
87
  const manifest = readManifest(cwd);
78
88
  const peers = resolvePeerFlags(cwd, {});
79
89
  const placement = resolvePlacement(cwd);
80
90
  const files = managedFiles(peers, placement);
91
+ const migration = migrateBlockToAgentsMd(cwd, manifest, { dryRun: opts.check === true });
81
92
  let updated = 0;
82
93
  let created = 0;
83
94
  let recreated = 0;
@@ -155,10 +166,15 @@ function sync(opts = {}, invocationCwd = process.cwd()) {
155
166
  `basalt-ui sync --check: ${retired.length} retired rule/skill file(s) still present: ${retired.join(", ")} \u2014 run \`basalt-ui sync\`.`
156
167
  );
157
168
  }
169
+ if (migration.pending) {
170
+ console.error(
171
+ `basalt-ui sync --check: the basalt block is still in ${LEGACY_BLOCK_HOST} \u2014 it now lives in ${AGENTS_MD}. Run \`basalt-ui sync\` to move it.`
172
+ );
173
+ }
158
174
  if (staleForCheck > 0) {
159
175
  console.error(`basalt-ui sync --check: ${staleForCheck} managed file(s) out of date.`);
160
- return 1;
161
176
  }
177
+ if (staleForCheck > 0 || migration.pending) return 1;
162
178
  const n = placementSkips.length;
163
179
  const skips = n === 0 ? "" : ` (${n} permanent placement skip(s) \u2014 --verbose for detail)`;
164
180
  console.log(`\u2713 basalt-ui sync --check: all managed files current.${skips}`);
@@ -172,6 +188,7 @@ function sync(opts = {}, invocationCwd = process.cwd()) {
172
188
  `basalt-ui sync: ${updated} updated, ${created} created, ${recreated} recreated, ${skippedDrift} skipped (drift), ${pruned.removed.length} retired.`
173
189
  );
174
190
  reportPrune(pruned);
191
+ reportBlockMigration(migration, "sync");
175
192
  if (driftLines.length > 0) {
176
193
  console.log("Locally-edited files were skipped (run with --force to overwrite):");
177
194
  for (const l of driftLines) console.log(l);
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * The check + tooltip are the entire success signal — no notification (docs/CONTENT-SPEC.md §7).
8
8
  * `color="teal"` resolves through the bridged Mantine family, so it stays on the token layer in
9
- * both schemes (see the package CLAUDE.md on `cssVariablesResolver`).
9
+ * both schemes (see the package MAINTAINING.md on `cssVariablesResolver`).
10
10
  *
11
11
  * Not part of the public surface — reach it through `CodeBlock` / `HeadingAnchor`.
12
12
  */
@@ -4,7 +4,7 @@
4
4
  * hand-drawn ones that drift apart.
5
5
  *
6
6
  * Inline SVG rather than an icon dep: the framework takes icons as `ReactNode` from the consumer
7
- * (see the package CLAUDE.md) and ships none, but these are internal chrome of a shipped component,
7
+ * (see the package MAINTAINING.md) and ships none, but these are internal chrome of a shipped component,
8
8
  * not a consumer-facing slot. `currentColor` + `1em`-agnostic sizing keeps them steerable from CSS
9
9
  * — the heading anchor sizes its glyph as an optical ratio of whatever step the heading lands on
10
10
  * (docs/DESIGN-SPEC.md §3), while the code-block chrome pins a fixed 14px.
@@ -103,7 +103,7 @@ export type BasaltVirtualListHandle = {
103
103
  /**
104
104
  * The underlying `@tanstack/react-virtual` instance — the full escape hatch. Returns the RAW
105
105
  * `@tanstack/react-virtual` `Virtualizer`, so a future major of that library reaching this shape
106
- * ships here as a plain `feat:` under the no-majors doctrine (`../../CLAUDE.md` "Commit type
106
+ * ships here as a plain `feat:` under the no-majors doctrine (root `AGENTS.md` "Commit type
107
107
  * discipline"), not a silent break.
108
108
  */
109
109
  getVirtualizer: () => Virtualizer<HTMLDivElement, Element>;
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # basalt-ui v1.40.3
1
+ # basalt-ui v1.41.0
2
2
  # Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets
3
3
  #
4
4
  # basalt-ui is an opinionated framework for Mantine-based React apps.
@@ -19,7 +19,7 @@
19
19
  # ./tokens.css subpath. Guide: docs/FRAMEWORK-FREE.md in the repo.
20
20
 
21
21
  Package: basalt-ui
22
- Version: 1.40.3
22
+ Version: 1.41.0
23
23
 
24
24
  ## basalt-ui
25
25
  Import: import { ... } from 'basalt-ui'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basalt-ui",
3
- "version": "1.40.3",
3
+ "version": "1.41.0",
4
4
  "description": "Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets",
5
5
  "keywords": [
6
6
  "charts",
@@ -97,8 +97,9 @@ export type PartRenderContext<TPart = ForeignPart> = {
97
97
  export type PartRenderer<TPart = ForeignPart> = (ctx: PartRenderContext<TPart>) => ReactNode
98
98
 
99
99
  /**
100
- * Canonical const-generic factory (`packages/basalt-ui/CLAUDE.md`, "Canonical token-factory
101
- * contract") — identity passthrough, exact-keyed return, no builder, no config bag. Mirrors
100
+ * Canonical const-generic factory (`packages/basalt-ui/MAINTAINING.md` § "Token system — the
101
+ * derived palette", "Series color is consumer data") — identity passthrough, exact-keyed return,
102
+ * no builder, no config bag. Mirrors
102
103
  * `defineSeries` (`tokens/index.ts:648-650`) and `defineNotifications`
103
104
  * (`notifications/define-notifications.ts:112-115`).
104
105
  *
@@ -166,8 +166,8 @@ export {
166
166
  } from './sparklines'
167
167
 
168
168
  // ── Re-exported visx primitives ──────────────────────────────────────────
169
- // Bespoke charts (genuinely unique compositions per CLAUDE.md) need raw
170
- // visx primitives. Re-exporting them keeps the dependency declared in one
169
+ // Bespoke charts (genuinely unique compositions, per `agent/rules/basalt-charts.md` "Adding a
170
+ // chart") need raw visx primitives. Re-exporting them keeps the dependency declared in one
171
171
  // place and preserves the rule that consumers only import from `basalt-ui/charts`.
172
172
  export { Group } from '@visx/group'
173
173
  export { GridRows, GridColumns } from '@visx/grid'
package/src/cli/doctor.ts CHANGED
@@ -8,6 +8,8 @@ import { relative, resolve } from 'node:path'
8
8
 
9
9
  import type { BasaltConfig } from './config'
10
10
  import {
11
+ AGENTS_MD,
12
+ LEGACY_BLOCK_HOST,
11
13
  MANIFEST_PATH,
12
14
  basaltBinCommand,
13
15
  basaltPresetEntry,
@@ -15,6 +17,7 @@ import {
15
17
  declaredProfile,
16
18
  findBasaltInstall,
17
19
  findRepoRoot,
20
+ importsAgentsMd,
18
21
  inspectLefthookGate,
19
22
  OXLINT_FILES,
20
23
  OXLINT_NO_BIN,
@@ -24,12 +27,23 @@ import {
24
27
  readIfExists,
25
28
  readManifest,
26
29
  resolveProjectDir,
30
+ sameFile,
27
31
  scannableFiles,
32
+ scanBlock,
28
33
  shippedAssetPath,
29
34
  } from './index'
30
35
  import { resolveRoots } from './config'
31
36
  import { findManifestAbove, parentInstallAdvice } from './sync'
32
37
 
38
+ /** `sameFile`, but doctor never crashes: a path that cannot be inspected is "not provably the same". */
39
+ function isSameFile(a: string, b: string): boolean {
40
+ try {
41
+ return sameFile(a, b)
42
+ } catch {
43
+ return false
44
+ }
45
+ }
46
+
33
47
  export type DoctorResult = {
34
48
  /** Number of hard failures (exit non-zero). */
35
49
  hardFailures: number
@@ -108,7 +122,7 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
108
122
  * Check a consumer repo's basalt integration and print a pass/warn report.
109
123
  *
110
124
  * Narrowed to what a single-directory-scoped run can actually verify — no workspace-glob walking,
111
- * no ascend/descend project discovery (see {@link resolveProjectDir}). Six checks, numbered in
125
+ * no ascend/descend project discovery (see {@link resolveProjectDir}). Seven checks, numbered in
112
126
  * the order they run and print:
113
127
  *
114
128
  * Hard failures (exit non-zero):
@@ -122,7 +136,7 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
122
136
  * preset AND that path resolves (warns when no `oxlint` bin does). `init` keeps an existing config, so the framework's whole
123
137
  * lint half can be silently off — one repo carried six real violations invisibly across five
124
138
  * minors this way.
125
- * 6. `ai-major-parity`: within THIS package.json, `dependencies`/`devDependencies`/
139
+ * 7. `ai-major-parity`: within THIS package.json, `dependencies`/`devDependencies`/
126
140
  * `peerDependencies` agree on the `ai` package's major — the shape `basalt/ai-sdk-major` (per
127
141
  * linted FILE) cannot see. A skew is exempt-able via `basalt.aiMajorSkewReason` (a non-empty
128
142
  * reason, not a bare `true`); a declared reason when the majors agree warns it is stale.
@@ -134,6 +148,9 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
134
148
  * gate elsewhere); a config that resolves but wires no guard command warns; a config lefthook
135
149
  * dump could not read at all (binary missing, or the dump failed) warns and says so, rather
136
150
  * than inventing a verdict.
151
+ * 6. `claude-md`: a project-root `CLAUDE.md` that does not import `@AGENTS.md` suppresses
152
+ * `AGENTS.md` (and with it the managed block); one that still carries a basalt block — or a
153
+ * malformed one — says so. A `CLAUDE.md` symlinked to `AGENTS.md` is one file and passes.
137
154
  *
138
155
  * Returns the exit code: 0 = all good, 1 = one or more hard failures.
139
156
  */
@@ -193,7 +210,7 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
193
210
 
194
211
  const install = findBasaltInstall(cwd)
195
212
 
196
- // ── Hard check 2: install matches the manifest, and doctor says WHERE (the CLAUDE-block's claim)
213
+ // ── Hard check 2: install matches the manifest, and doctor says WHERE (the AGENTS-block's claim)
197
214
  const manifestVersion = profile === 'tokens-only' ? undefined : readManifest(cwd).basaltVersion
198
215
  const at = install.dir === null ? '' : `${relative(cwd, install.dir)} (${install.version})`
199
216
  if (install.dir === null) {
@@ -300,7 +317,33 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
300
317
  )
301
318
  }
302
319
 
303
- // ── Hard check 6: ai package major version parity within THIS package.json ─
320
+ // ── Warn check 6: a project-root CLAUDE.md that shadows AGENTS.md ───────────
321
+ // The managed block lives in AGENTS.md. Claude Code loads a repo-root AGENTS.md directly, but ANY
322
+ // CLAUDE.md in or above the cwd suppresses it unless that file imports `@AGENTS.md` — so a
323
+ // leftover CLAUDE.md means the block (and the rest of AGENTS.md) is not loaded, whether or not it
324
+ // still carries a block. A symlink to AGENTS.md is one file and is fine. Warn only: `sync`
325
+ // migrates the block, `sync --check` is the gate, and what else CLAUDE.md holds is the consumer's.
326
+ const legacyHost = resolve(cwd, LEGACY_BLOCK_HOST)
327
+ const legacy = profile === 'tokens-only' ? null : readIfExists(legacyHost)
328
+ if (legacy !== null && !isSameFile(legacyHost, resolve(cwd, AGENTS_MD))) {
329
+ const scan = scanBlock(legacy)
330
+ const issues: string[] = []
331
+ if (scan.problem !== null) {
332
+ issues.push(`has a malformed basalt block (${scan.problem}) — fix it by hand, then \`sync\``)
333
+ } else if (scan.region !== null) {
334
+ issues.push(`still carries a stale basalt block — run \`basalt-ui sync\` to move it`)
335
+ }
336
+ if (!importsAgentsMd(legacy)) {
337
+ issues.push(
338
+ `does not import \`@${AGENTS_MD}\`, so Claude Code loads it INSTEAD of ${AGENTS_MD} and the ` +
339
+ `basalt block never reaches the agent — move its content into ${AGENTS_MD} and delete it, ` +
340
+ `or add \`@${AGENTS_MD}\``,
341
+ )
342
+ }
343
+ if (issues.length > 0) warn(`${LEGACY_BLOCK_HOST} ${issues.join('; and ')}.`)
344
+ }
345
+
346
+ // ── Hard check 7: ai package major version parity within THIS package.json ─
304
347
  // basalt/ai-sdk-major (the lint rule) is per-file against the NEAREST package.json, so a lint
305
348
  // run scoped to one workspace package only ever sees that package's own `ai` major and is
306
349
  // perfectly happy. This check reads the SAME package.json across its dependencies/