knodin 0.8.6 → 0.8.7

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.
@@ -0,0 +1,149 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ export const KNODIN_SKILLS = [
7
+ "knodin-explore",
8
+ "knodin-debug",
9
+ "knodin-impact",
10
+ "knodin-refactor",
11
+ "knodin-review",
12
+ "knodin-systems",
13
+ "knodin-lifecycle",
14
+ ];
15
+ const MANIFEST = ".knodin-managed.json";
16
+ function canonicalRoot() {
17
+ const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
18
+ const candidates = [
19
+ path.resolve(moduleDirectory, "..", "skills"),
20
+ path.resolve(moduleDirectory, "..", "..", "skills"),
21
+ ];
22
+ const root = candidates.find((candidate) => fs.existsSync(candidate));
23
+ if (!root)
24
+ throw new Error("knodin packaged skills are missing");
25
+ return root;
26
+ }
27
+ function destinationRoot(client, scope, repo, home) {
28
+ if (scope === "team") {
29
+ if (client === "codex")
30
+ return path.join(repo, ".agents", "skills");
31
+ if (client === "claude")
32
+ return path.join(repo, ".claude", "skills");
33
+ return path.join(repo, ".gemini", "skills");
34
+ }
35
+ if (client === "codex")
36
+ return path.join(home, ".agents", "skills");
37
+ if (client === "claude")
38
+ return path.join(home, ".claude", "skills");
39
+ return path.join(home, ".gemini", "skills");
40
+ }
41
+ function sourceDigest(source) {
42
+ const hash = crypto.createHash("sha256");
43
+ for (const relative of ["SKILL.md", path.join("agents", "openai.yaml")])
44
+ hash.update(fs.readFileSync(path.join(source, relative)));
45
+ return hash.digest("hex");
46
+ }
47
+ function managedState(target) {
48
+ try {
49
+ const manifest = JSON.parse(fs.readFileSync(path.join(target, MANIFEST), "utf8"));
50
+ if (manifest.owner !== "knodin")
51
+ return "unmanaged";
52
+ return manifest.digest === sourceDigest(target) ? "managed" : "modified";
53
+ }
54
+ catch {
55
+ return "unmanaged";
56
+ }
57
+ }
58
+ export function installKnodinSkills(options) {
59
+ const result = {
60
+ scope: options.scope,
61
+ installed: [],
62
+ removed: [],
63
+ unchanged: [],
64
+ issues: [],
65
+ };
66
+ if (options.scope === "cli-only")
67
+ return result;
68
+ const home = options.home ?? os.homedir();
69
+ for (const client of options.clients) {
70
+ const root = destinationRoot(client, options.scope, path.resolve(options.repo), home);
71
+ for (const name of KNODIN_SKILLS) {
72
+ const source = path.join(canonicalRoot(), name);
73
+ const target = path.join(root, name);
74
+ const digest = sourceDigest(source);
75
+ if (fs.existsSync(target)) {
76
+ const state = managedState(target);
77
+ if (state !== "managed") {
78
+ result.issues.push({
79
+ path: target,
80
+ message: `${state} skill collision; left unchanged`,
81
+ });
82
+ continue;
83
+ }
84
+ }
85
+ try {
86
+ const current = JSON.parse(fs.readFileSync(path.join(target, MANIFEST), "utf8"));
87
+ if (current.digest === digest) {
88
+ result.unchanged.push(target);
89
+ continue;
90
+ }
91
+ }
92
+ catch {
93
+ // Missing managed version is installed below.
94
+ }
95
+ result.installed.push(target);
96
+ if (options.dryRun)
97
+ continue;
98
+ fs.mkdirSync(root, { recursive: true });
99
+ const temporary = path.join(root, `.${name}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`);
100
+ fs.cpSync(source, temporary, { recursive: true, errorOnExist: true });
101
+ fs.writeFileSync(path.join(temporary, MANIFEST), `${JSON.stringify({ owner: "knodin", digest, client }, null, 2)}\n`);
102
+ if (fs.existsSync(target))
103
+ fs.renameSync(target, `${temporary}.old`);
104
+ fs.renameSync(temporary, target);
105
+ fs.rmSync(`${temporary}.old`, { recursive: true, force: true });
106
+ }
107
+ }
108
+ return result;
109
+ }
110
+ export function removeKnodinSkills(options) {
111
+ const result = {
112
+ scope: options.scope,
113
+ installed: [],
114
+ removed: [],
115
+ unchanged: [],
116
+ issues: [],
117
+ };
118
+ const home = options.home ?? os.homedir();
119
+ for (const client of options.clients) {
120
+ const root = destinationRoot(client, options.scope, path.resolve(options.repo), home);
121
+ for (const name of KNODIN_SKILLS) {
122
+ const target = path.join(root, name);
123
+ if (!fs.existsSync(target))
124
+ continue;
125
+ const state = managedState(target);
126
+ if (state !== "managed") {
127
+ result.issues.push({ path: target, message: `${state} skill collision; left unchanged` });
128
+ continue;
129
+ }
130
+ result.removed.push(target);
131
+ if (!options.dryRun)
132
+ fs.rmSync(target, { recursive: true });
133
+ }
134
+ if (!options.dryRun) {
135
+ try {
136
+ fs.rmdirSync(root);
137
+ fs.rmdirSync(path.dirname(root));
138
+ }
139
+ catch (error) {
140
+ if (!["ENOENT", "ENOTEMPTY"].includes(error.code ?? ""))
141
+ throw error;
142
+ }
143
+ }
144
+ }
145
+ return result;
146
+ }
147
+ export function inspectKnodinSkills(options) {
148
+ return installKnodinSkills({ ...options, dryRun: true });
149
+ }
@@ -190,6 +190,113 @@ function applyPersonalMappings(repositories, xdgConfigHome) {
190
190
  repository.pathSource = "personal-config";
191
191
  }
192
192
  }
193
+ function personalSystemDocuments(repo, xdgConfigHome) {
194
+ const knodin = path.join(xdgConfigHome, "knodin");
195
+ const personal = asRecord(optionalYaml(path.join(knodin, "repositories.yaml")));
196
+ const mappings = asRecord(personal.repositories);
197
+ const localIds = new Set(Object.entries(mappings)
198
+ .filter(([, checkout]) => typeof checkout === "string" && path.resolve(checkout) === repo)
199
+ .map(([id]) => id));
200
+ if (localIds.size === 0)
201
+ return [];
202
+ let names;
203
+ try {
204
+ names = fs.readdirSync(path.join(knodin, "systems"));
205
+ }
206
+ catch (error) {
207
+ if (error.code === "ENOENT")
208
+ return [];
209
+ throw error;
210
+ }
211
+ const systemRoot = path.join(knodin, "systems");
212
+ const manifestPaths = names.flatMap((name) => {
213
+ const candidate = path.join(systemRoot, name);
214
+ if (name.endsWith(".yaml") || name.endsWith(".yml"))
215
+ return [candidate];
216
+ try {
217
+ if (!fs.statSync(candidate).isDirectory())
218
+ return [];
219
+ const nested = path.join(candidate, "knodin.yaml");
220
+ return fs.existsSync(nested) ? [nested] : [];
221
+ }
222
+ catch {
223
+ return [];
224
+ }
225
+ });
226
+ const sortedManifestPaths = [...manifestPaths];
227
+ sortedManifestPaths.sort(compareBytes);
228
+ return sortedManifestPaths
229
+ .flatMap((manifest) => {
230
+ try {
231
+ return [asRecord(readYaml(manifest))];
232
+ }
233
+ catch (error) {
234
+ const source = fs.readFileSync(manifest, "utf8");
235
+ if ([...localIds].some((id) => source.includes(id)))
236
+ throw error;
237
+ return [];
238
+ }
239
+ })
240
+ .filter((document) => {
241
+ const repositories = Array.isArray(document.repositories) ? document.repositories : [];
242
+ return repositories.some((value) => localIds.has(String(asRecord(value).id ?? "")));
243
+ });
244
+ }
245
+ function registeredSystemDocuments(repo, xdgConfigHome) {
246
+ const personal = asRecord(optionalYaml(path.join(xdgConfigHome, "knodin", "repositories.yaml")));
247
+ const mappings = asRecord(personal.repositories);
248
+ const localIds = new Set(Object.entries(mappings)
249
+ .filter(([, checkout]) => typeof checkout === "string" && path.resolve(checkout) === repo)
250
+ .map(([id]) => id));
251
+ if (localIds.size === 0)
252
+ return [];
253
+ const manifests = asRecord(personal.systemManifests);
254
+ const documents = [];
255
+ for (const registration of Object.values(manifests)) {
256
+ const record = asRecord(registration);
257
+ const paths = Array.isArray(record.manifests) ? record.manifests : [];
258
+ for (const manifest of paths) {
259
+ if (typeof manifest !== "string" || !path.isAbsolute(manifest) || !fs.existsSync(manifest))
260
+ continue;
261
+ const document = asRecord(readYaml(manifest));
262
+ const participates = (Array.isArray(document.repositories) ? document.repositories : []).some((value) => localIds.has(String(asRecord(value).id ?? "")));
263
+ if (participates)
264
+ documents.push(document);
265
+ }
266
+ }
267
+ return documents;
268
+ }
269
+ function availableTeamFragments(team, repo, xdgConfigHome) {
270
+ const systemIds = new Set((Array.isArray(team.systems) ? team.systems : []).map((value) => String(asRecord(value).id ?? "")));
271
+ if (systemIds.size === 0)
272
+ return [];
273
+ const personal = asRecord(optionalYaml(path.join(xdgConfigHome, "knodin", "repositories.yaml")));
274
+ const mappings = asRecord(personal.repositories);
275
+ const documents = [];
276
+ for (const checkout of Object.values(mappings)) {
277
+ if (typeof checkout !== "string" || path.resolve(checkout) === repo)
278
+ continue;
279
+ const manifest = path.join(checkout, "knodin.yaml");
280
+ if (!fs.existsSync(manifest))
281
+ continue;
282
+ const document = asRecord(readYaml(manifest));
283
+ const overlaps = (Array.isArray(document.systems) ? document.systems : []).some((value) => systemIds.has(String(asRecord(value).id ?? "")));
284
+ if (overlaps)
285
+ documents.push(document);
286
+ }
287
+ return documents;
288
+ }
289
+ function deduplicateDeclarations(values, kind) {
290
+ const unique = new Map();
291
+ for (const value of values) {
292
+ const existing = unique.get(value.id);
293
+ if (!existing)
294
+ unique.set(value.id, value);
295
+ else if (JSON.stringify(existing) !== JSON.stringify(value))
296
+ throw new Error(`conflicting ${kind} identity ${value.id} across available system fragments`);
297
+ }
298
+ return [...unique.values()];
299
+ }
193
300
  function configuredCheckout(repository, repositories, repo) {
194
301
  if (repository.path)
195
302
  return repository.path;
@@ -313,6 +420,11 @@ function declaredRelationship(value, index) {
313
420
  const from = stringValue(record.from, `relationships[${index}].from`);
314
421
  const to = stringValue(record.to, `relationships[${index}].to`);
315
422
  const source = asRecord(record.evidence);
423
+ const evidenceRepository = typeof source.repository === "string" && source.repository ? source.repository : undefined;
424
+ const evidencePath = typeof source.path === "string" && source.path ? source.path : undefined;
425
+ const evidenceLine = typeof source.line === "number" && Number.isSafeInteger(source.line) && source.line > 0
426
+ ? source.line
427
+ : undefined;
316
428
  const confidence = typeof source.confidence === "number" && source.confidence >= 0 && source.confidence <= 1
317
429
  ? source.confidence
318
430
  : 1;
@@ -322,7 +434,14 @@ function declaredRelationship(value, index) {
322
434
  to,
323
435
  targetIdentity: to,
324
436
  evidence: {
325
- location: typeof source.location === "string" && source.location ? source.location : "knodin.yaml",
437
+ location: typeof source.location === "string" && source.location
438
+ ? source.location
439
+ : evidencePath
440
+ ? `${evidenceRepository ? `${evidenceRepository}:` : ""}${evidencePath}${evidenceLine ? `:${evidenceLine}` : ""}`
441
+ : "knodin.yaml",
442
+ ...(evidenceRepository ? { repository: evidenceRepository } : {}),
443
+ ...(evidencePath ? { path: evidencePath } : {}),
444
+ ...(evidenceLine ? { line: evidenceLine } : {}),
326
445
  adapter: "knodin-yaml",
327
446
  kind: "declared",
328
447
  confidence,
@@ -337,11 +456,31 @@ export function loadSystemConfiguration(repoPath, options = {}) {
337
456
  if (team.schemaVersion !== undefined && team.schemaVersion !== 1) {
338
457
  throw new Error("knodin.yaml: schemaVersion must be 1");
339
458
  }
340
- const repositories = (Array.isArray(team.repositories) ? team.repositories : []).map((value, index) => configuredRepository(value, index));
341
- const systems = (Array.isArray(team.systems) ? team.systems : []).map((value, index) => configuredSystem(value, index));
342
- const relationships = (Array.isArray(team.relationships) ? team.relationships : []).map((value, index) => declaredRelationship(value, index));
343
- const initialization = repositoryInitialization(team.repositoryInitialization);
344
459
  const xdgConfigHome = options.xdgConfigHome ?? process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config");
460
+ const documents = [
461
+ team,
462
+ ...availableTeamFragments(team, repo, xdgConfigHome),
463
+ ...registeredSystemDocuments(repo, xdgConfigHome),
464
+ ...personalSystemDocuments(repo, xdgConfigHome),
465
+ ];
466
+ const repositories = deduplicateDeclarations(documents
467
+ .flatMap((document) => (Array.isArray(document.repositories) ? document.repositories : []))
468
+ .map((value, index) => configuredRepository(value, index)), "repository");
469
+ const systems = deduplicateDeclarations(documents
470
+ .flatMap((document) => (Array.isArray(document.systems) ? document.systems : []))
471
+ .map((value, index) => configuredSystem(value, index)), "system");
472
+ const relationships = documents
473
+ .flatMap((document) => (Array.isArray(document.relationships) ? document.relationships : []))
474
+ .map((value, index) => declaredRelationship(value, index));
475
+ const declaredIdentities = new Map();
476
+ for (const relationship of relationships) {
477
+ const identity = `${relationship.type}\0${relationship.from}\0${relationship.to}`;
478
+ const existing = declaredIdentities.get(identity);
479
+ if (existing && JSON.stringify(existing.evidence) !== JSON.stringify(relationship.evidence))
480
+ throw new Error(`conflicting relationship ${relationship.type} ${relationship.from} -> ${relationship.to} across available system fragments`);
481
+ declaredIdentities.set(identity, relationship);
482
+ }
483
+ const initialization = repositoryInitialization(team.repositoryInitialization);
345
484
  applyPersonalMappings(repositories, xdgConfigHome);
346
485
  const legacyFederation = applyLegacyFederation(repo, repositories, relationships);
347
486
  for (const repository of repositories) {
@@ -485,34 +624,55 @@ export async function validateSystemHealth(config, systemId, status, defaultRepo
485
624
  }
486
625
  return { valid: issues.length === 0, requiresPartial: issues.length > 0, issues };
487
626
  }
488
- export function queryConfiguredSystem(config, systemId, allowPartial = false, healthValidation) {
627
+ export function queryConfiguredSystem(config, systemId, requireComplete = false, healthValidation) {
489
628
  const system = config.systems.find(({ id }) => id === systemId);
490
629
  if (!system) {
491
630
  return { status: "not-found", systemId, available: config.systems.map(({ id }) => id) };
492
631
  }
493
632
  const validation = healthValidation ?? validateSystemConfiguration(config);
494
- if (!validation.valid && !allowPartial) {
633
+ const componentRepository = new Map(system.components.flatMap(({ id, repository }) => repository ? [[id, repository]] : []));
634
+ const unavailableRepositories = new Set(validation.issues.flatMap(({ code, identity }) => {
635
+ if (code === "unresolved-repository" || code === "missing-checkout")
636
+ return [identity];
637
+ if (code === "unavailable-component") {
638
+ const repository = componentRepository.get(identity);
639
+ return repository ? [repository] : [];
640
+ }
641
+ return [];
642
+ }));
643
+ if (!validation.valid && requireComplete) {
495
644
  return {
496
645
  status: "unavailable",
497
646
  systemId,
498
647
  relationships: [],
648
+ omittedRelationships: [],
649
+ unavailableRepositories: [...unavailableRepositories].sort(compareBytes),
499
650
  omissions: validation.issues,
500
651
  };
501
652
  }
502
- const unavailableRepositories = new Set(validation.issues
503
- .filter(({ code }) => code === "unresolved-repository" || code === "missing-checkout")
504
- .map(({ identity }) => identity));
505
- const omittedComponents = new Set(system.components
506
- .filter(({ repository }) => repository !== undefined && unavailableRepositories.has(repository))
507
- .map(({ id }) => id));
653
+ const omittedComponents = new Set([
654
+ ...system.components
655
+ .filter(({ repository }) => repository !== undefined && unavailableRepositories.has(repository))
656
+ .map(({ id }) => id),
657
+ ...validation.issues
658
+ .filter(({ code }) => code === "unavailable-component")
659
+ .map(({ identity }) => identity),
660
+ ]);
508
661
  const componentIds = new Set(system.components.map(({ id }) => id));
509
- const relationships = config.relationships.filter(({ from, to }) => (componentIds.has(from) || componentIds.has(to)) &&
510
- !omittedComponents.has(from) &&
511
- !omittedComponents.has(to));
662
+ const relevantRelationships = config.relationships.filter(({ from, to }) => componentIds.has(from) || componentIds.has(to));
663
+ const invalidRelationships = new Set(validation.issues
664
+ .filter(({ code }) => ["invalid-relationship", "low-confidence-relationship", "configuration-drift"].includes(code))
665
+ .map(({ identity }) => identity));
666
+ const relationships = relevantRelationships.filter(({ from, to }) => !omittedComponents.has(from) &&
667
+ !omittedComponents.has(to) &&
668
+ !invalidRelationships.has(`${from}->${to}`));
669
+ const returned = new Set(relationships);
512
670
  return {
513
671
  status: validation.valid ? "ok" : "partial",
514
672
  systemId,
515
673
  relationships,
674
+ omittedRelationships: relevantRelationships.filter((relationship) => !returned.has(relationship)),
675
+ unavailableRepositories: [...unavailableRepositories].sort(compareBytes),
516
676
  omissions: validation.issues,
517
677
  };
518
678
  }