@dujavi/ai-md 0.5.0 → 0.6.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/lib/build.js ADDED
@@ -0,0 +1,268 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const crypto = require("crypto");
6
+ const { ensureDir, fingerprintTree, walkFiles, hashFile } = require("./link");
7
+ const { selectHarnesses, ensureAgentSourceDirs } = require("./harnesses");
8
+
9
+ function sharedRoots(cfg) {
10
+ return {
11
+ rules: path.join(cfg.dir, "shared", "rules"),
12
+ skills: path.join(cfg.dir, "shared", "skills"),
13
+ };
14
+ }
15
+
16
+ function agentRoots(cfg, id) {
17
+ const srcId = id === "codex" ? "agents" : id;
18
+ return {
19
+ rules: path.join(cfg.dir, "agents", srcId, "rules"),
20
+ skills: path.join(cfg.dir, "agents", srcId, "skills"),
21
+ };
22
+ }
23
+
24
+ function distRoot(cfg, distId) {
25
+ return path.join(cfg.dir, "dist", distId);
26
+ }
27
+
28
+ function listRuleFiles(dir) {
29
+ if (!fs.existsSync(dir)) return [];
30
+ return fs
31
+ .readdirSync(dir)
32
+ .filter((f) => f.endsWith(".mdc") || f.endsWith(".md"))
33
+ .sort();
34
+ }
35
+
36
+ function listSkillDirs(dir) {
37
+ if (!fs.existsSync(dir)) return [];
38
+ return fs
39
+ .readdirSync(dir, { withFileTypes: true })
40
+ .filter((d) => d.isDirectory() && !d.name.startsWith("."))
41
+ .map((d) => d.name)
42
+ .sort();
43
+ }
44
+
45
+ function copyFile(src, dest) {
46
+ ensureDir(path.dirname(dest));
47
+ fs.copyFileSync(src, dest);
48
+ }
49
+
50
+ function copyDir(src, dest) {
51
+ fs.cpSync(src, dest, { recursive: true, force: true });
52
+ }
53
+
54
+ /** Merge shared then agent; agent wins on basename. */
55
+ function mergeRules(sharedDir, agentDir, format) {
56
+ const map = new Map(); // basename without forcing ext
57
+ for (const f of listRuleFiles(sharedDir)) {
58
+ map.set(f.replace(/\.(mdc|md)$/i, ""), {
59
+ src: path.join(sharedDir, f),
60
+ name: f,
61
+ layer: "shared",
62
+ });
63
+ }
64
+ for (const f of listRuleFiles(agentDir)) {
65
+ const key = f.replace(/\.(mdc|md)$/i, "");
66
+ map.set(key, {
67
+ src: path.join(agentDir, f),
68
+ name: f,
69
+ layer: "agent",
70
+ });
71
+ }
72
+ const out = [];
73
+ for (const [, ent] of map) {
74
+ let destName = ent.name;
75
+ if (format === "md" && destName.endsWith(".mdc")) {
76
+ destName = destName.slice(0, -4) + ".md";
77
+ } else if (format === "mdc" && destName.endsWith(".md") && !destName.endsWith(".mdc")) {
78
+ // keep .md as-is for mdc harness if already md; cursor prefers .mdc
79
+ if (!destName.endsWith(".mdc")) destName = destName.replace(/\.md$/i, ".mdc");
80
+ }
81
+ out.push({ ...ent, destName });
82
+ }
83
+ return out;
84
+ }
85
+
86
+ function mergeSkills(sharedDir, agentDir) {
87
+ const map = new Map();
88
+ for (const name of listSkillDirs(sharedDir)) {
89
+ map.set(name, { src: path.join(sharedDir, name), layer: "shared" });
90
+ }
91
+ for (const name of listSkillDirs(agentDir)) {
92
+ map.set(name, { src: path.join(agentDir, name), layer: "agent" });
93
+ }
94
+ return [...map.entries()].map(([name, v]) => ({ name, ...v }));
95
+ }
96
+
97
+ function readManifest(distDir) {
98
+ const p = path.join(distDir, ".build-manifest.json");
99
+ try {
100
+ return JSON.parse(fs.readFileSync(p, "utf8"));
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ function distDirty(distDir) {
107
+ const manifest = readManifest(distDir);
108
+ if (!manifest || !manifest.files) {
109
+ if (!fs.existsSync(distDir)) return { dirty: false, files: [] };
110
+ // no manifest but files exist
111
+ const files = walkFiles(distDir);
112
+ return {
113
+ dirty: files.length > 0,
114
+ files: files.map((f) => f.rel),
115
+ reason: "missing_manifest",
116
+ };
117
+ }
118
+ const current = fingerprintTree(distDir);
119
+ const dirtyFiles = [];
120
+ for (const [rel, hash] of Object.entries(manifest.files)) {
121
+ if (current[rel] !== hash) dirtyFiles.push(rel);
122
+ }
123
+ for (const rel of Object.keys(current)) {
124
+ if (!manifest.files[rel]) dirtyFiles.push(rel);
125
+ }
126
+ return { dirty: dirtyFiles.length > 0, files: dirtyFiles, reason: "drift" };
127
+ }
128
+
129
+ function emitHarness(cfg, harness, { dryRun = false, verbose = false } = {}) {
130
+ const distId = harness.distId;
131
+ const outDir = distRoot(cfg, distId);
132
+ const shared = sharedRoots(cfg);
133
+ const agent = agentRoots(cfg, harness.id);
134
+ ensureAgentSourceDirs(cfg, harness.id);
135
+
136
+ const overrides = [];
137
+ const rules =
138
+ harness.format === "skills-only"
139
+ ? []
140
+ : mergeRules(shared.rules, agent.rules, harness.format);
141
+ const skills = mergeSkills(shared.skills, agent.skills);
142
+
143
+ for (const r of rules) {
144
+ if (r.layer === "agent") overrides.push(`rules/${r.destName}`);
145
+ }
146
+ for (const s of skills) {
147
+ if (s.layer === "agent") overrides.push(`skills/${s.name}`);
148
+ }
149
+
150
+ if (dryRun) {
151
+ return {
152
+ id: harness.id,
153
+ distId,
154
+ action: "would_build",
155
+ rules: rules.length,
156
+ skills: skills.length,
157
+ overrides: verbose ? overrides : overrides.length,
158
+ outDir,
159
+ };
160
+ }
161
+
162
+ fs.rmSync(outDir, { recursive: true, force: true });
163
+ ensureDir(path.join(outDir, "skills"));
164
+ if (harness.format !== "skills-only") ensureDir(path.join(outDir, "rules"));
165
+
166
+ for (const r of rules) {
167
+ copyFile(r.src, path.join(outDir, "rules", r.destName));
168
+ }
169
+ for (const s of skills) {
170
+ copyDir(s.src, path.join(outDir, "skills", s.name));
171
+ }
172
+
173
+ const files = fingerprintTree(outDir);
174
+ const manifest = {
175
+ version: 1,
176
+ harness: harness.id,
177
+ distId,
178
+ format: harness.format,
179
+ builtAt: new Date().toISOString(),
180
+ inputsHash: crypto
181
+ .createHash("sha256")
182
+ .update(JSON.stringify({ rules, skills: skills.map((s) => s.name) }))
183
+ .digest("hex"),
184
+ files,
185
+ overrides,
186
+ };
187
+ fs.writeFileSync(
188
+ path.join(outDir, ".build-manifest.json"),
189
+ JSON.stringify(manifest, null, 2) + "\n"
190
+ );
191
+
192
+ return {
193
+ id: harness.id,
194
+ distId,
195
+ action: "built",
196
+ rules: rules.length,
197
+ skills: skills.length,
198
+ overrides: verbose ? overrides : overrides.length,
199
+ outDir,
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Build dist for selected harnesses. Refuses if dirty unless force.
205
+ */
206
+ function runBuild(cfg, opts = {}) {
207
+ const {
208
+ agents,
209
+ force = false,
210
+ dryRun = false,
211
+ verbose = false,
212
+ forceLink = false,
213
+ includeUninstalled = false,
214
+ } = opts;
215
+
216
+ const { selected, skipped, warnings } = selectHarnesses(cfg, agents, {
217
+ forceLink,
218
+ includeUninstalled,
219
+ });
220
+
221
+ // Deduplicate by distId for emit
222
+ const byDist = new Map();
223
+ for (const h of selected) {
224
+ if (!byDist.has(h.distId)) byDist.set(h.distId, h);
225
+ }
226
+
227
+ const dirtyBlocks = [];
228
+ for (const [distId] of byDist) {
229
+ const d = distDirty(distRoot(cfg, distId));
230
+ if (d.dirty && !force) {
231
+ dirtyBlocks.push({ distId, ...d });
232
+ }
233
+ }
234
+
235
+ if (dirtyBlocks.length && !dryRun) {
236
+ const err = new Error(
237
+ `dist has edits not in source (${dirtyBlocks.map((d) => d.distId).join(", ")}). ` +
238
+ `Run: ai-md rescue --agents <id> or ai-md build --force`
239
+ );
240
+ err.code = "EDIRTY";
241
+ err.dirty = dirtyBlocks;
242
+ throw err;
243
+ }
244
+
245
+ const results = [];
246
+ for (const h of byDist.values()) {
247
+ results.push(emitHarness(cfg, h, { dryRun, verbose }));
248
+ }
249
+
250
+ return {
251
+ built: results,
252
+ skipped,
253
+ warnings,
254
+ dirtyForced: force ? dirtyBlocks : [],
255
+ };
256
+ }
257
+
258
+ module.exports = {
259
+ sharedRoots,
260
+ agentRoots,
261
+ distRoot,
262
+ mergeRules,
263
+ mergeSkills,
264
+ distDirty,
265
+ readManifest,
266
+ runBuild,
267
+ emitHarness,
268
+ };