baldart 3.18.1 → 3.21.1
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/CHANGELOG.md +192 -0
- package/README.md +2 -1
- package/VERSION +1 -1
- package/bin/baldart.js +30 -0
- package/framework/.claude/agents/REGISTRY.md +2 -0
- package/framework/.claude/hooks/agent-discovery-gate.js +167 -0
- package/framework/.claude/hooks/agent-discovery-info.sh +104 -0
- package/framework/.claude/hooks/framework-edit-gate.js +5 -1
- package/framework/.claude/skills/baldart-update/SKILL.md +300 -0
- package/framework/.claude/skills/overlay/SKILL.md +214 -0
- package/framework/AGENTS.md +1 -0
- package/framework/templates/overlays/README.md +13 -0
- package/package.json +1 -1
- package/src/commands/add.js +16 -11
- package/src/commands/doctor.js +138 -13
- package/src/commands/overlay.js +381 -0
- package/src/commands/update.js +18 -6
- package/src/utils/hooks.js +357 -86
- package/src/utils/overlay-merger.js +17 -4
package/src/commands/doctor.js
CHANGED
|
@@ -212,8 +212,47 @@ async function detectState(cwd, opts = {}) {
|
|
|
212
212
|
state.overlays = countOverlays(cwd);
|
|
213
213
|
state.overlaysDrifted = overlayDrift(cwd, state.frameworkVersion);
|
|
214
214
|
|
|
215
|
-
state.
|
|
216
|
-
|
|
215
|
+
state.hooksStatus = { malformed: false, hooks: [] };
|
|
216
|
+
state.hooksMissing = [];
|
|
217
|
+
state.hooksDrifted = [];
|
|
218
|
+
state.hooksLegacy = [];
|
|
219
|
+
try {
|
|
220
|
+
const status = Hooks.getStatus(cwd);
|
|
221
|
+
state.hooksStatus = status;
|
|
222
|
+
if (!status.malformed) {
|
|
223
|
+
state.hooksMissing = status.hooks.filter((h) => !h.registered).map((h) => h.id);
|
|
224
|
+
state.hooksDrifted = status.hooks.filter((h) => h.registered && h.drift && !h.legacy).map((h) => h.id);
|
|
225
|
+
state.hooksLegacy = status.hooks.filter((h) => h.legacy).map((h) => h.id);
|
|
226
|
+
}
|
|
227
|
+
} catch (_) {}
|
|
228
|
+
|
|
229
|
+
// ---- Agent symlink integrity (since v3.20.0) -----------------------
|
|
230
|
+
// Source of truth for the BALDART agent list is the framework filesystem
|
|
231
|
+
// directory itself (`.framework/framework/.claude/agents/*.md`). For each
|
|
232
|
+
// name, the consumer must have a working `.claude/agents/<name>.md`
|
|
233
|
+
// (symlink to the framework copy, possibly merged with an overlay).
|
|
234
|
+
// A missing or broken symlink would otherwise be detected at runtime by
|
|
235
|
+
// `agent-discovery-gate.js`, blocking Agent calls. This probe lets the
|
|
236
|
+
// doctor heal it offline before the user hits the gate.
|
|
237
|
+
state.agentSymlinksBroken = [];
|
|
238
|
+
try {
|
|
239
|
+
if (state.frameworkPresent) {
|
|
240
|
+
const frameworkAgentsDir = path.join(cwd, '.framework', 'framework', '.claude', 'agents');
|
|
241
|
+
const consumerAgentsDir = path.join(cwd, '.claude', 'agents');
|
|
242
|
+
if (fs.existsSync(frameworkAgentsDir)) {
|
|
243
|
+
const baldartAgents = fs.readdirSync(frameworkAgentsDir)
|
|
244
|
+
.filter((f) => f.endsWith('.md') && f !== 'REGISTRY.md')
|
|
245
|
+
.map((f) => f.replace(/\.md$/, ''));
|
|
246
|
+
for (const name of baldartAgents) {
|
|
247
|
+
try {
|
|
248
|
+
fs.statSync(path.join(consumerAgentsDir, `${name}.md`));
|
|
249
|
+
} catch (_) {
|
|
250
|
+
state.agentSymlinksBroken.push(name);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} catch (_) { /* never block doctor on probe */ }
|
|
217
256
|
|
|
218
257
|
// `.framework/` is a git subtree — gitignore matching it breaks every
|
|
219
258
|
// future `git add` under that path (the failure mode that hit v3.13.0
|
|
@@ -410,15 +449,75 @@ function planActions(state) {
|
|
|
410
449
|
// Non-blocking — keep looking for other actions to run in the same pass.
|
|
411
450
|
}
|
|
412
451
|
|
|
413
|
-
|
|
452
|
+
// BALDART hooks (multi-hook registry since v3.18.0).
|
|
453
|
+
// Three buckets, three actions: missing → propose; legacy → propose
|
|
454
|
+
// (silent migration to current id); drifted → require interactive prompt.
|
|
455
|
+
if ((state.hooksMissing && state.hooksMissing.length > 0)
|
|
456
|
+
|| (state.hooksLegacy && state.hooksLegacy.length > 0)) {
|
|
457
|
+
const ids = [...(state.hooksMissing || []), ...(state.hooksLegacy || [])];
|
|
414
458
|
actions.push({
|
|
415
|
-
key: 'register-
|
|
416
|
-
label:
|
|
417
|
-
why: '
|
|
459
|
+
key: 'register-hooks',
|
|
460
|
+
label: `Register/migrate BALDART hook(s) in .claude/settings.json (${ids.join(', ')})`,
|
|
461
|
+
why: 'BALDART hooks are missing or registered under a pre-v3.18 layout. They run on PreToolUse/Stop/SessionStart to gate edits and feed the auto-learning loop. Re-registration is idempotent and never overwrites user-customized commands without asking.',
|
|
418
462
|
autoOk: true,
|
|
419
463
|
run: async () => {
|
|
420
|
-
const
|
|
421
|
-
|
|
464
|
+
const Hooks = require('../utils/hooks');
|
|
465
|
+
const res = await Hooks.registerAll(process.cwd(), {
|
|
466
|
+
onDrift: Hooks.createDriftPrompt(UI, { autoYes: state.autoMode }),
|
|
467
|
+
});
|
|
468
|
+
if (res.malformed) {
|
|
469
|
+
UI.warning('.claude/settings.json is malformed; fix it manually then re-run `npx baldart doctor`.');
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
for (const r of res.results) {
|
|
473
|
+
if (r.status === 'created') UI.success(`Registered hook \`${r.id}\``);
|
|
474
|
+
else if (r.status === 'legacy-migrated') UI.success(`Migrated legacy hook to \`${r.id}\` (previous: ${r.previousCommand})`);
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
if (state.hooksDrifted && state.hooksDrifted.length > 0) {
|
|
480
|
+
actions.push({
|
|
481
|
+
key: 'reconcile-hooks-drift',
|
|
482
|
+
label: `Reconcile drifted BALDART hook(s) (${state.hooksDrifted.join(', ')})`,
|
|
483
|
+
why: 'These hooks are registered but their command differs from the BALDART default. This is typically a sign of a manual customization or a stale framework version on disk. Reconciliation prompts you per hook (Keep / Replace / Show diff).',
|
|
484
|
+
autoOk: false, // drift reconciliation is the kind of decision --auto must NOT make
|
|
485
|
+
run: async () => {
|
|
486
|
+
const Hooks = require('../utils/hooks');
|
|
487
|
+
const res = await Hooks.registerAll(process.cwd(), {
|
|
488
|
+
onDrift: Hooks.createDriftPrompt(UI), // always interactive here
|
|
489
|
+
});
|
|
490
|
+
for (const r of res.results) {
|
|
491
|
+
if (r.status === 'drift-replaced') UI.success(`Replaced \`${r.id}\` (was: ${r.previousCommand})`);
|
|
492
|
+
else if (r.status === 'drift-kept') UI.info(`Kept \`${r.id}\` as-is.`);
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Agent symlink integrity (since v3.20.0). Re-runs the agent merge loop to
|
|
499
|
+
// recreate any missing per-agent symlink. Calls `SymlinkUtils.mergeAgents`
|
|
500
|
+
// directly rather than dispatching to `update` — we want only the symlink
|
|
501
|
+
// pass, not a full update (which would also pull, merge overlays, register
|
|
502
|
+
// hooks, and verify config drift).
|
|
503
|
+
if (state.agentSymlinksBroken && state.agentSymlinksBroken.length > 0) {
|
|
504
|
+
actions.push({
|
|
505
|
+
key: 'repair-agent-symlinks',
|
|
506
|
+
label: `Repair ${state.agentSymlinksBroken.length} broken agent symlink(s)`,
|
|
507
|
+
why: `BALDART expects these agents to be reachable via .claude/agents/<name>.md but the symlinks are missing or broken: ${state.agentSymlinksBroken.join(', ')}. Without these, the agent-discovery-gate PreToolUse hook will deny Agent calls targeting these names.`,
|
|
508
|
+
autoOk: true,
|
|
509
|
+
run: async () => {
|
|
510
|
+
const SymlinkUtils = require('../utils/symlinks');
|
|
511
|
+
// Re-read the config here (not stashed on state) to pick up
|
|
512
|
+
// `tools.enabled`. Falls back to ['claude'] if absent/malformed.
|
|
513
|
+
const cfg = loadConfig(process.cwd());
|
|
514
|
+
const enabledTools = (cfg && !cfg.__malformed
|
|
515
|
+
&& cfg.tools && Array.isArray(cfg.tools.enabled))
|
|
516
|
+
? cfg.tools.enabled
|
|
517
|
+
: ['claude'];
|
|
518
|
+
const symlinks = new SymlinkUtils();
|
|
519
|
+
symlinks.mergeAgents({ tools: enabledTools });
|
|
520
|
+
UI.success(`Re-merged agent symlinks for tools: ${enabledTools.join(', ')}`);
|
|
422
521
|
},
|
|
423
522
|
});
|
|
424
523
|
}
|
|
@@ -616,11 +715,36 @@ function renderDiagnostic(state) {
|
|
|
616
715
|
console.log(` • ${o.name} targets v${o.target}, installed v${o.installed}`));
|
|
617
716
|
}
|
|
618
717
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
state.
|
|
622
|
-
state.
|
|
623
|
-
|
|
718
|
+
{
|
|
719
|
+
const total = state.hooksStatus && state.hooksStatus.hooks ? state.hooksStatus.hooks.length : 0;
|
|
720
|
+
const missing = state.hooksMissing ? state.hooksMissing.length : 0;
|
|
721
|
+
const drifted = state.hooksDrifted ? state.hooksDrifted.length : 0;
|
|
722
|
+
const legacy = state.hooksLegacy ? state.hooksLegacy.length : 0;
|
|
723
|
+
const registered = total - missing;
|
|
724
|
+
let summary;
|
|
725
|
+
let status;
|
|
726
|
+
if (state.hooksStatus && state.hooksStatus.malformed) {
|
|
727
|
+
summary = '.claude/settings.json is malformed';
|
|
728
|
+
status = 'warn';
|
|
729
|
+
} else if (missing === 0 && drifted === 0 && legacy === 0) {
|
|
730
|
+
summary = `${registered}/${total} registered`;
|
|
731
|
+
status = 'ok';
|
|
732
|
+
} else {
|
|
733
|
+
const parts = [`${registered}/${total} registered`];
|
|
734
|
+
if (missing) parts.push(`${missing} missing`);
|
|
735
|
+
if (drifted) parts.push(`${drifted} drifted`);
|
|
736
|
+
if (legacy) parts.push(`${legacy} pre-v3.18`);
|
|
737
|
+
summary = parts.join(', ');
|
|
738
|
+
status = 'warn';
|
|
739
|
+
}
|
|
740
|
+
console.log(statusLine('BALDART hooks', summary, status));
|
|
741
|
+
if (state.hooksMissing && state.hooksMissing.length)
|
|
742
|
+
console.log(` • missing: ${state.hooksMissing.join(', ')}`);
|
|
743
|
+
if (state.hooksDrifted && state.hooksDrifted.length)
|
|
744
|
+
console.log(` • drifted: ${state.hooksDrifted.join(', ')}`);
|
|
745
|
+
if (state.hooksLegacy && state.hooksLegacy.length)
|
|
746
|
+
console.log(` • pre-v3.18: ${state.hooksLegacy.join(', ')}`);
|
|
747
|
+
}
|
|
624
748
|
|
|
625
749
|
if (state.frameworkIgnored && state.frameworkIgnored.ignored) {
|
|
626
750
|
console.log(statusLine(
|
|
@@ -663,6 +787,7 @@ async function doctor(opts = {}) {
|
|
|
663
787
|
const offline = !!opts.offline;
|
|
664
788
|
|
|
665
789
|
const state = await detectState(cwd, { offline });
|
|
790
|
+
state.autoMode = auto;
|
|
666
791
|
renderDiagnostic(state);
|
|
667
792
|
|
|
668
793
|
const actions = planActions(state);
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const yaml = require('js-yaml');
|
|
4
|
+
const UI = require('../utils/ui');
|
|
5
|
+
const { mergeOverlay, computeBaseFileSha } = require('../utils/overlay-merger');
|
|
6
|
+
|
|
7
|
+
const FRAMEWORK_DIR = '.framework';
|
|
8
|
+
const FRAMEWORK_PAYLOAD = path.join(FRAMEWORK_DIR, 'framework');
|
|
9
|
+
const OVERLAYS_DIR = path.join('.baldart', 'overlays');
|
|
10
|
+
|
|
11
|
+
const KINDS = ['skill', 'agent', 'command'];
|
|
12
|
+
|
|
13
|
+
function frameworkRoot(cwd) {
|
|
14
|
+
return path.join(cwd, FRAMEWORK_DIR);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function ensureConsumer(cwd) {
|
|
18
|
+
if (!fs.existsSync(frameworkRoot(cwd))) {
|
|
19
|
+
UI.error('Not a BALDART consumer repo — `.framework/` not found.');
|
|
20
|
+
UI.info('Run `npx baldart` to install the framework first.');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readFrameworkVersion(cwd) {
|
|
26
|
+
try {
|
|
27
|
+
return fs.readFileSync(path.join(cwd, FRAMEWORK_DIR, 'VERSION'), 'utf8').trim();
|
|
28
|
+
} catch (_) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function baseFilePathFor(cwd, kind, name) {
|
|
34
|
+
if (kind === 'skill') {
|
|
35
|
+
return path.join(cwd, FRAMEWORK_PAYLOAD, '.claude', 'skills', name, 'SKILL.md');
|
|
36
|
+
}
|
|
37
|
+
return path.join(cwd, FRAMEWORK_PAYLOAD, '.claude', `${kind}s`, `${name}.md`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function overlayPathFor(cwd, kind, name) {
|
|
41
|
+
if (kind === 'skill') return path.join(cwd, OVERLAYS_DIR, `${name}.md`);
|
|
42
|
+
return path.join(cwd, OVERLAYS_DIR, `${kind}s`, `${name}.md`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseTarget(target) {
|
|
46
|
+
// Accept "agents/coder", "commands/codexreview", "skill/bug", or bare name
|
|
47
|
+
// (skill assumed). Always normalise to { kind: 'agent'|'command'|'skill', name }.
|
|
48
|
+
if (!target || typeof target !== 'string') return null;
|
|
49
|
+
if (target.includes('/')) {
|
|
50
|
+
const [head, ...rest] = target.split('/');
|
|
51
|
+
const name = rest.join('/');
|
|
52
|
+
if (!name) return null;
|
|
53
|
+
if (head === 'skill' || head === 'skills') return { kind: 'skill', name };
|
|
54
|
+
if (head === 'agent' || head === 'agents') return { kind: 'agent', name };
|
|
55
|
+
if (head === 'command' || head === 'commands') return { kind: 'command', name };
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return { kind: 'skill', name: target };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseFrontmatter(content) {
|
|
62
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
63
|
+
if (!m) return { fm: {}, found: false };
|
|
64
|
+
try {
|
|
65
|
+
const fm = yaml.load(m[1]) || {};
|
|
66
|
+
// Coerce `base_file_sha` to string — a 12-char hex SHA may be all digits,
|
|
67
|
+
// which YAML would otherwise parse as a number (0 → falsy → false drift).
|
|
68
|
+
if (fm.base_file_sha != null) fm.base_file_sha = String(fm.base_file_sha);
|
|
69
|
+
return { fm, found: true };
|
|
70
|
+
} catch (_) {
|
|
71
|
+
return { fm: {}, found: true, malformed: true };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function scaffoldBody(kind, name) {
|
|
76
|
+
if (kind === 'skill') {
|
|
77
|
+
return [
|
|
78
|
+
'',
|
|
79
|
+
`> Skill overlays are concatenated to the base SKILL.md at runtime by the Claude`,
|
|
80
|
+
`> agent. The merger does NOT parse [OVERRIDE]/[APPEND]/[PREPEND] markers here —`,
|
|
81
|
+
`> they are textual conventions for the agent. Use \`mode: override\` in`,
|
|
82
|
+
`> frontmatter to replace the base skill entirely.`,
|
|
83
|
+
'',
|
|
84
|
+
`## [APPEND] Project-specific guidance`,
|
|
85
|
+
'',
|
|
86
|
+
`<!-- Add the rules / vocabulary / opinions that apply to THIS project. -->`,
|
|
87
|
+
'',
|
|
88
|
+
].join('\n');
|
|
89
|
+
}
|
|
90
|
+
return [
|
|
91
|
+
'',
|
|
92
|
+
`> Section markers (parsed by overlay-merger.js):`,
|
|
93
|
+
`> ## [OVERRIDE] <H2> — replace base section verbatim`,
|
|
94
|
+
`> ## [APPEND] <H2> — add overlay content after base section`,
|
|
95
|
+
`> ## [PREPEND] <H2> — add overlay content before base section`,
|
|
96
|
+
`> ## <H2> — appended as a new section at the end`,
|
|
97
|
+
'',
|
|
98
|
+
`## [APPEND] <heading from base ${kind} "${name}">`,
|
|
99
|
+
'',
|
|
100
|
+
`<!-- Replace the heading above with one that exists in the base file. -->`,
|
|
101
|
+
'',
|
|
102
|
+
].join('\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildFrontmatter({ kind, name, frameworkVersion, baseSha, mode }) {
|
|
106
|
+
const lines = ['---'];
|
|
107
|
+
lines.push(`base_${kind}: ${name}`);
|
|
108
|
+
lines.push(`base_${kind}_version: ${frameworkVersion || 'unknown'}`);
|
|
109
|
+
// Quote the SHA — a 12-char hex string can be all digits, which YAML would
|
|
110
|
+
// otherwise parse as a number (0 → falsy → false-negative in drift check).
|
|
111
|
+
if (baseSha) lines.push(`base_file_sha: "${baseSha}"`);
|
|
112
|
+
lines.push(`mode: ${mode || 'extend'}`);
|
|
113
|
+
lines.push('---');
|
|
114
|
+
return lines.join('\n') + '\n';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── scaffold ──────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
async function scaffold(target, options = {}) {
|
|
120
|
+
const cwd = process.cwd();
|
|
121
|
+
ensureConsumer(cwd);
|
|
122
|
+
|
|
123
|
+
const parsed = parseTarget(target);
|
|
124
|
+
if (!parsed) {
|
|
125
|
+
UI.error('Invalid target. Use: skill/<name>, agents/<name>, or commands/<name>.');
|
|
126
|
+
UI.info('Example: npx baldart overlay scaffold agents/coder');
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
const { kind, name } = parsed;
|
|
130
|
+
|
|
131
|
+
const baseAbs = baseFilePathFor(cwd, kind, name);
|
|
132
|
+
if (!fs.existsSync(baseAbs)) {
|
|
133
|
+
UI.error(`Base ${kind} "${name}" not found at ${path.relative(cwd, baseAbs)}.`);
|
|
134
|
+
UI.info(`Run \`ls ${path.relative(cwd, path.dirname(baseAbs))}\` to list available ${kind}s.`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const overlayAbs = overlayPathFor(cwd, kind, name);
|
|
139
|
+
const overlayRel = path.relative(cwd, overlayAbs);
|
|
140
|
+
|
|
141
|
+
if (fs.existsSync(overlayAbs)) {
|
|
142
|
+
UI.warning(`Overlay already exists: ${overlayRel}`);
|
|
143
|
+
UI.info('Run `npx baldart overlay drift ' + overlayRel + '` to check it, or edit it directly to extend.');
|
|
144
|
+
process.exit(0);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
fs.mkdirSync(path.dirname(overlayAbs), { recursive: true });
|
|
148
|
+
|
|
149
|
+
const baseContent = fs.readFileSync(baseAbs, 'utf8');
|
|
150
|
+
const baseSha = computeBaseFileSha(baseContent);
|
|
151
|
+
const fwVersion = readFrameworkVersion(cwd);
|
|
152
|
+
const mode = (options.mode || 'extend').trim();
|
|
153
|
+
if (!['extend', 'override'].includes(mode)) {
|
|
154
|
+
UI.error(`Invalid --mode "${mode}". Use "extend" or "override".`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const content =
|
|
159
|
+
buildFrontmatter({ kind, name, frameworkVersion: fwVersion, baseSha, mode }) +
|
|
160
|
+
scaffoldBody(kind, name);
|
|
161
|
+
|
|
162
|
+
fs.writeFileSync(overlayAbs, content);
|
|
163
|
+
UI.success(`Created ${overlayRel}`);
|
|
164
|
+
UI.info(`Base file: ${path.relative(cwd, baseAbs)}`);
|
|
165
|
+
UI.info(`Targets framework v${fwVersion || 'unknown'} (base_file_sha=${baseSha}).`);
|
|
166
|
+
UI.newline();
|
|
167
|
+
UI.list([
|
|
168
|
+
`Edit ${overlayRel} to add your customisations.`,
|
|
169
|
+
`Validate the merge with: npx baldart overlay validate ${overlayRel}`,
|
|
170
|
+
kind === 'skill'
|
|
171
|
+
? `Run any /-command that uses this skill to see the overlay applied at runtime.`
|
|
172
|
+
: `Apply with: npx baldart update (regenerates .claude/${kind}s/${name}.md from base + overlay).`,
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── validate ──────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
function deriveTargetFromOverlayPath(cwd, overlayPath) {
|
|
179
|
+
const abs = path.isAbsolute(overlayPath) ? overlayPath : path.join(cwd, overlayPath);
|
|
180
|
+
const root = path.join(cwd, OVERLAYS_DIR);
|
|
181
|
+
const rel = path.relative(root, abs);
|
|
182
|
+
if (rel.startsWith('..')) return null;
|
|
183
|
+
const parts = rel.split(path.sep);
|
|
184
|
+
if (parts.length === 1) {
|
|
185
|
+
return { kind: 'skill', name: parts[0].replace(/\.md$/, ''), abs };
|
|
186
|
+
}
|
|
187
|
+
if (parts.length === 2) {
|
|
188
|
+
const sub = parts[0];
|
|
189
|
+
if (sub === 'agents') return { kind: 'agent', name: parts[1].replace(/\.md$/, ''), abs };
|
|
190
|
+
if (sub === 'commands') return { kind: 'command', name: parts[1].replace(/\.md$/, ''), abs };
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function validate(overlayPath) {
|
|
196
|
+
const cwd = process.cwd();
|
|
197
|
+
ensureConsumer(cwd);
|
|
198
|
+
|
|
199
|
+
if (!overlayPath) {
|
|
200
|
+
UI.error('Usage: npx baldart overlay validate <path>');
|
|
201
|
+
UI.info('Example: npx baldart overlay validate .baldart/overlays/agents/coder.md');
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const t = deriveTargetFromOverlayPath(cwd, overlayPath);
|
|
206
|
+
if (!t) {
|
|
207
|
+
UI.error(`Path is not under ${OVERLAYS_DIR}/ or has unexpected shape: ${overlayPath}`);
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
if (!fs.existsSync(t.abs)) {
|
|
211
|
+
UI.error(`Overlay file not found: ${path.relative(cwd, t.abs)}`);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const overlayContent = fs.readFileSync(t.abs, 'utf8');
|
|
216
|
+
const { fm, found, malformed } = parseFrontmatter(overlayContent);
|
|
217
|
+
if (!found) {
|
|
218
|
+
UI.error('Overlay has no YAML frontmatter.');
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
if (malformed) {
|
|
222
|
+
UI.error('Overlay frontmatter is not valid YAML.');
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
const expectedKindField = `base_${t.kind}`;
|
|
226
|
+
if (fm[expectedKindField] !== t.name) {
|
|
227
|
+
UI.error(`Frontmatter mismatch: expected ${expectedKindField}: ${t.name} (got ${fm[expectedKindField] || 'missing'}).`);
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const baseAbs = baseFilePathFor(cwd, t.kind, t.name);
|
|
232
|
+
if (!fs.existsSync(baseAbs)) {
|
|
233
|
+
UI.error(`Base ${t.kind} "${t.name}" not found at ${path.relative(cwd, baseAbs)}.`);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (t.kind === 'skill') {
|
|
238
|
+
UI.success(`Overlay valid (skill, concat model).`);
|
|
239
|
+
UI.info(`Targets v${fm.base_skill_version || 'unknown'}, mode=${fm.mode || 'extend'}.`);
|
|
240
|
+
if (fm.base_file_sha) {
|
|
241
|
+
const currentSha = computeBaseFileSha(fs.readFileSync(baseAbs, 'utf8'));
|
|
242
|
+
if (currentSha !== fm.base_file_sha) {
|
|
243
|
+
UI.warning(`Base file changed since overlay was authored (sha ${fm.base_file_sha} → ${currentSha}). Review for drift.`);
|
|
244
|
+
} else {
|
|
245
|
+
UI.success('Base file unchanged since overlay was authored.');
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
UI.info('No base_file_sha in frontmatter — drift check by version only.');
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Agent/command — dry-run the merger.
|
|
254
|
+
const baseContent = fs.readFileSync(baseAbs, 'utf8');
|
|
255
|
+
const fwVersion = readFrameworkVersion(cwd);
|
|
256
|
+
let result;
|
|
257
|
+
try {
|
|
258
|
+
result = mergeOverlay({
|
|
259
|
+
kind: t.kind,
|
|
260
|
+
name: t.name,
|
|
261
|
+
baseContent,
|
|
262
|
+
overlayContent,
|
|
263
|
+
frameworkVersion: fwVersion
|
|
264
|
+
});
|
|
265
|
+
} catch (err) {
|
|
266
|
+
UI.error(`Merger threw: ${err.message}`);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (result.warnings && result.warnings.length) {
|
|
271
|
+
result.warnings.forEach((w) => UI.warning(w));
|
|
272
|
+
}
|
|
273
|
+
UI.success(`Overlay valid (${t.kind}, marker model).`);
|
|
274
|
+
UI.info(`Targets v${result.baseVersionTargeted}, mode=${result.overlayMode}.`);
|
|
275
|
+
UI.info(`Generated output: ${result.generated.split('\n').length} lines.`);
|
|
276
|
+
if (fm.base_file_sha) {
|
|
277
|
+
const currentSha = computeBaseFileSha(baseContent);
|
|
278
|
+
if (currentSha !== fm.base_file_sha) {
|
|
279
|
+
UI.warning(`Base file changed since overlay was authored (sha ${fm.base_file_sha} → ${currentSha}). Review for drift.`);
|
|
280
|
+
} else {
|
|
281
|
+
UI.success('Base file unchanged since overlay was authored.');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── drift ─────────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
function collectOverlays(cwd) {
|
|
289
|
+
const root = path.join(cwd, OVERLAYS_DIR);
|
|
290
|
+
if (!fs.existsSync(root)) return [];
|
|
291
|
+
const out = [];
|
|
292
|
+
const pushFrom = (sub, kind) => {
|
|
293
|
+
const dir = sub ? path.join(root, sub) : root;
|
|
294
|
+
if (!fs.existsSync(dir)) return;
|
|
295
|
+
for (const f of fs.readdirSync(dir)) {
|
|
296
|
+
if (!f.endsWith('.md')) continue;
|
|
297
|
+
if (f.endsWith('.example.md')) continue;
|
|
298
|
+
if (sub === '' && (f === 'README.md')) continue;
|
|
299
|
+
out.push({ kind, name: f.replace(/\.md$/, ''), abs: path.join(dir, f), rel: path.join(OVERLAYS_DIR, sub || '', f) });
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
pushFrom('', 'skill');
|
|
303
|
+
pushFrom('agents', 'agent');
|
|
304
|
+
pushFrom('commands', 'command');
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function drift(target, options = {}) {
|
|
309
|
+
const cwd = process.cwd();
|
|
310
|
+
ensureConsumer(cwd);
|
|
311
|
+
|
|
312
|
+
const fwVersion = readFrameworkVersion(cwd);
|
|
313
|
+
const all = !!options.all || !target;
|
|
314
|
+
const targets = [];
|
|
315
|
+
if (all) {
|
|
316
|
+
targets.push(...collectOverlays(cwd));
|
|
317
|
+
} else {
|
|
318
|
+
const t = deriveTargetFromOverlayPath(cwd, target);
|
|
319
|
+
if (!t) {
|
|
320
|
+
UI.error(`Path is not under ${OVERLAYS_DIR}/ or has unexpected shape: ${target}`);
|
|
321
|
+
process.exit(1);
|
|
322
|
+
}
|
|
323
|
+
if (!fs.existsSync(t.abs)) {
|
|
324
|
+
UI.error(`Overlay not found: ${path.relative(cwd, t.abs)}`);
|
|
325
|
+
process.exit(1);
|
|
326
|
+
}
|
|
327
|
+
targets.push({ kind: t.kind, name: t.name, abs: t.abs, rel: path.relative(cwd, t.abs) });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (targets.length === 0) {
|
|
331
|
+
UI.info('No overlays authored.');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let drifted = 0;
|
|
336
|
+
let unknown = 0;
|
|
337
|
+
let clean = 0;
|
|
338
|
+
for (const t of targets) {
|
|
339
|
+
const content = fs.readFileSync(t.abs, 'utf8');
|
|
340
|
+
const { fm } = parseFrontmatter(content);
|
|
341
|
+
const versionField = `base_${t.kind}_version`;
|
|
342
|
+
const overlayVersion = fm[versionField];
|
|
343
|
+
const baseAbs = baseFilePathFor(cwd, t.kind, t.name);
|
|
344
|
+
const baseExists = fs.existsSync(baseAbs);
|
|
345
|
+
|
|
346
|
+
if (!baseExists) {
|
|
347
|
+
UI.error(`${t.rel}: base ${t.kind} "${t.name}" no longer exists in framework.`);
|
|
348
|
+
drifted++;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const baseSha = computeBaseFileSha(fs.readFileSync(baseAbs, 'utf8'));
|
|
353
|
+
let shaDrift = null;
|
|
354
|
+
if (fm.base_file_sha) {
|
|
355
|
+
shaDrift = fm.base_file_sha !== baseSha;
|
|
356
|
+
}
|
|
357
|
+
const versionDrift = overlayVersion && fwVersion && overlayVersion !== fwVersion;
|
|
358
|
+
|
|
359
|
+
if (shaDrift === true) {
|
|
360
|
+
UI.warning(`${t.rel}: base file changed since overlay was authored (sha ${fm.base_file_sha} → ${baseSha}).`);
|
|
361
|
+
drifted++;
|
|
362
|
+
} else if (shaDrift === false) {
|
|
363
|
+
UI.success(`${t.rel}: clean (sha matches base v${fwVersion}).`);
|
|
364
|
+
clean++;
|
|
365
|
+
} else if (versionDrift) {
|
|
366
|
+
UI.warning(`${t.rel}: targets v${overlayVersion}, installed v${fwVersion} — review (no base_file_sha to confirm).`);
|
|
367
|
+
unknown++;
|
|
368
|
+
} else if (overlayVersion) {
|
|
369
|
+
UI.info(`${t.rel}: targets v${overlayVersion} (no base_file_sha — drift unknown).`);
|
|
370
|
+
unknown++;
|
|
371
|
+
} else {
|
|
372
|
+
UI.warning(`${t.rel}: no base_${t.kind}_version in frontmatter.`);
|
|
373
|
+
unknown++;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
UI.newline();
|
|
378
|
+
UI.info(`Summary: ${clean} clean, ${drifted} drifted, ${unknown} unknown — of ${targets.length} overlay(s).`);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
module.exports = { scaffold, validate, drift };
|
package/src/commands/update.js
CHANGED
|
@@ -400,13 +400,25 @@ async function update(options = {}) {
|
|
|
400
400
|
UI.warning(`Routines wizard skipped: ${err.message}`);
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
403
|
+
// BALDART hooks (multi-hook registry since v3.18.0) — backfill for
|
|
404
|
+
// consumers that predate any of the hooks shipped in the current
|
|
405
|
+
// framework version. Idempotent: no-op when every hook is already in
|
|
406
|
+
// place. Drift on an existing entry triggers an interactive prompt
|
|
407
|
+
// (Keep / Replace / Show diff); on --auto / -y the default is 'keep'.
|
|
406
408
|
try {
|
|
407
|
-
const res = Hooks.
|
|
408
|
-
|
|
409
|
-
|
|
409
|
+
const res = await Hooks.registerAll(process.cwd(), {
|
|
410
|
+
onDrift: Hooks.createDriftPrompt(UI, { autoYes }),
|
|
411
|
+
});
|
|
412
|
+
if (res.malformed) {
|
|
413
|
+
UI.warning('.claude/settings.json is malformed; skipped hook registration. Run `npx baldart doctor` after fixing it.');
|
|
414
|
+
} else {
|
|
415
|
+
for (const r of res.results) {
|
|
416
|
+
if (r.status === 'created') UI.success(`Registered hook \`${r.id}\` in .claude/settings.json (v3.18.0+ default).`);
|
|
417
|
+
else if (r.status === 'legacy-migrated') UI.success(`Migrated legacy hook to \`${r.id}\` (previous: ${r.previousCommand})`);
|
|
418
|
+
else if (r.status === 'drift-replaced') UI.success(`Replaced drifted hook \`${r.id}\` (was: ${r.previousCommand})`);
|
|
419
|
+
else if (r.status === 'drift-kept') UI.info(`Kept user-customized hook \`${r.id}\` as-is.`);
|
|
420
|
+
// 'already' is silent on update to avoid noise
|
|
421
|
+
}
|
|
410
422
|
}
|
|
411
423
|
} catch (err) {
|
|
412
424
|
UI.warning(`Hook registration skipped: ${err.message}`);
|