knodin 0.8.5 → 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.
@@ -901,7 +901,12 @@ function decodeCursor(cursor, hash) {
901
901
  (value.repository ?? -1) < 0 ||
902
902
  (value.offset ?? -1) < 0)
903
903
  throw new Error("invalid cursor");
904
- return { repository: value.repository ?? 0, offset: value.offset ?? 0 };
904
+ return {
905
+ repository: value.repository ?? 0,
906
+ offset: value.offset ?? 0,
907
+ ...(value.mode === "fallback" ? { mode: "fallback" } : {}),
908
+ ...(value.mode === "symbol-only" ? { mode: "symbol-only" } : {}),
909
+ };
905
910
  }
906
911
  catch {
907
912
  throw new Error("knodin repos search: cursor does not match this query and selection");
@@ -930,8 +935,146 @@ function searchCandidate(inventory, match, exposePaths) {
930
935
  confidence: match.rrfScore || match.similarity,
931
936
  freshness: inventory.freshness,
932
937
  health: "healthy",
938
+ matchType: "symbol",
939
+ systemMemberships: inventory.systemMemberships,
933
940
  };
934
941
  }
942
+ const TRACKED_TEXT_CANDIDATE_LIMIT = 1000;
943
+ const TRACKED_TEXT_REPOSITORY_LIMIT = 200;
944
+ const TRACKED_TEXT_OUTPUT_LIMIT = 2 * 1024 * 1024;
945
+ const TRACKED_TEXT_TIMEOUT_MS = 5000;
946
+ function queryTokens(query) {
947
+ return [
948
+ ...new Set(query
949
+ .toLowerCase()
950
+ .match(/[a-z0-9][a-z0-9._-]{2,}/g)
951
+ ?.map((token) => token.replace(/^[._-]+|[._-]+$/g, ""))
952
+ .filter(Boolean) ?? []),
953
+ ];
954
+ }
955
+ function tokenCoverage(value, tokens) {
956
+ const lower = value.toLowerCase();
957
+ return tokens.filter((token) => lower.includes(token)).length;
958
+ }
959
+ function textFallbackReason(results, query) {
960
+ if (results.length === 0)
961
+ return "no-symbol-results";
962
+ const tokens = queryTokens(query);
963
+ if (tokens.length < 3)
964
+ return undefined;
965
+ return results.every((result) => {
966
+ const identityCoverage = tokenCoverage(`${result.symbol} ${result.file}`, tokens);
967
+ const sourceCoverage = tokenCoverage(result.sourceEvidence, tokens);
968
+ return identityCoverage < 2 && (identityCoverage === 0 || sourceCoverage < 2);
969
+ })
970
+ ? "weak-symbol-coverage"
971
+ : undefined;
972
+ }
973
+ function committedFile(repository, file, timeout) {
974
+ const result = child_process.spawnSync(gitExecutable(), ["show", `HEAD:${file}`], {
975
+ cwd: repository,
976
+ encoding: "buffer",
977
+ stdio: ["ignore", "pipe", "ignore"],
978
+ timeout,
979
+ maxBuffer: 1024 * 1024,
980
+ });
981
+ return result.status === 0 && !result.error ? result.stdout : undefined;
982
+ }
983
+ function trackedTextWindow(repository, file, line, timeout, cache) {
984
+ try {
985
+ let content = cache?.get(file);
986
+ if (!cache?.has(file)) {
987
+ content = committedFile(repository, file, timeout);
988
+ cache?.set(file, content);
989
+ }
990
+ if (!content || content.includes(0))
991
+ return "";
992
+ const lines = content.toString("utf8").split(/\r?\n/);
993
+ const start = Math.max(0, line - 3);
994
+ return lines
995
+ .slice(start, Math.min(lines.length, line + 2))
996
+ .map((value, index) => `${start + index + 1}: ${value}`)
997
+ .join("\n");
998
+ }
999
+ catch {
1000
+ return "";
1001
+ }
1002
+ }
1003
+ function trackedTextMatches(inventory, query, exposePaths) {
1004
+ if (!inventory.path)
1005
+ return { matches: [], truncated: false };
1006
+ const tokens = queryTokens(query);
1007
+ if (tokens.length === 0)
1008
+ return { matches: [], truncated: false };
1009
+ const started = Date.now();
1010
+ const remaining = () => Math.max(1, TRACKED_TEXT_TIMEOUT_MS - (Date.now() - started));
1011
+ const args = ["grep", "-n", "-I", "-F", "-i"];
1012
+ for (const token of tokens)
1013
+ args.push("-e", token);
1014
+ args.push("HEAD");
1015
+ const result = child_process.spawnSync(gitExecutable(), args, {
1016
+ cwd: inventory.path,
1017
+ encoding: "utf8",
1018
+ stdio: ["ignore", "pipe", "pipe"],
1019
+ timeout: remaining(),
1020
+ maxBuffer: TRACKED_TEXT_OUTPUT_LIMIT,
1021
+ });
1022
+ if (result.error && result.error.code !== "ENOBUFS")
1023
+ return { matches: [], truncated: true };
1024
+ const lines = String(result.stdout ?? "")
1025
+ .split(/\r?\n/)
1026
+ .filter(Boolean);
1027
+ const truncated = lines.length > TRACKED_TEXT_REPOSITORY_LIMIT || result.error !== undefined;
1028
+ const records = lines.flatMap((record) => {
1029
+ const match = /^HEAD:(.+?):(\d+):(.*)$/.exec(record);
1030
+ if (!match?.[1] || !match[2])
1031
+ return [];
1032
+ const file = match[1];
1033
+ const extension = path.extname(file).toLowerCase();
1034
+ if (![".yaml", ".yml", ".sh", ".bash", ".zsh", ""].includes(extension))
1035
+ return [];
1036
+ if (extension === "") {
1037
+ const content = committedFile(inventory.path ?? "", file, remaining());
1038
+ if (!content || !content.toString("utf8", 0, 128).startsWith("#!"))
1039
+ return [];
1040
+ }
1041
+ return [{ file, line: Number(match[2]), matchedSource: match[3] ?? "" }];
1042
+ });
1043
+ const phrase = query.trim().toLowerCase();
1044
+ const contentCache = new Map();
1045
+ const matches = records.slice(0, TRACKED_TEXT_REPOSITORY_LIMIT).flatMap((record) => {
1046
+ const { file, line, matchedSource } = record;
1047
+ const source = trackedTextWindow(inventory.path ?? "", file, line, remaining(), contentCache) ||
1048
+ matchedSource;
1049
+ const coverage = tokenCoverage(`${file} ${source}`, tokens);
1050
+ const exactPhrase = phrase.length > 0 && source.toLowerCase().includes(phrase) ? 1 : 0;
1051
+ const pathCoverage = tokenCoverage(file, tokens);
1052
+ return [
1053
+ {
1054
+ repositoryId: inventory.id,
1055
+ ...(exposePaths === false ? {} : { repositoryPath: inventory.path }),
1056
+ identity: `text_${crypto.createHash("sha256").update(`${inventory.id}\0${file}\0${line}`).digest("hex").slice(0, 20)}`,
1057
+ symbol: path.basename(file),
1058
+ kind: "tracked-text",
1059
+ file,
1060
+ line,
1061
+ sourceEvidence: source.slice(0, 4000),
1062
+ sourceTruncated: source.length > 4000,
1063
+ confidence: coverage / tokens.length + exactPhrase * 0.01 + pathCoverage * 0.001,
1064
+ freshness: inventory.freshness,
1065
+ health: "healthy",
1066
+ matchType: "tracked-text",
1067
+ systemMemberships: inventory.systemMemberships,
1068
+ _score: [coverage, exactPhrase, pathCoverage],
1069
+ },
1070
+ ];
1071
+ });
1072
+ matches.sort((left, right) => right._score[0] - left._score[0] ||
1073
+ right._score[1] - left._score[1] ||
1074
+ right._score[2] - left._score[2] ||
1075
+ compareBytes(`${left.repositoryId}:${left.file}:${left.line}`, `${right.repositoryId}:${right.file}:${right.line}`));
1076
+ return { matches: matches.map(({ _score: _ignored, ...match }) => match), truncated };
1077
+ }
935
1078
  async function searchHealthyRepository(inventory, repositoryIndex, offset, context) {
936
1079
  const { query, itemLimit, effectiveBytes, results, omissions, selected, options } = context;
937
1080
  const page = await options.search(query, inventory.path ?? "", itemLimit - results.length + 1, offset);
@@ -1013,6 +1156,7 @@ export async function searchRepositories(roots, query, options) {
1013
1156
  .digest("hex")
1014
1157
  .slice(0, 20);
1015
1158
  const start = decodeCursor(options.cursor, hash);
1159
+ const symbolStart = start.mode === "fallback" ? { repository: 0, offset: 0 } : start;
1016
1160
  const itemLimit = Math.min(Math.max(options.itemBudget ?? 25, 1), 1000);
1017
1161
  if (options.byteBudget !== undefined && options.byteBudget < 1024)
1018
1162
  throw new Error("knodin repos search: byte budget must be at least 1024");
@@ -1021,7 +1165,58 @@ export async function searchRepositories(roots, query, options) {
1021
1165
  const byteLimit = options.byteBudget ?? 65_536;
1022
1166
  const tokenLimit = options.tokenBudget ?? 16_384;
1023
1167
  const effectiveBytes = Math.min(byteLimit, tokenLimit * 4);
1024
- const { results, omissions, next } = await collectRepositorySearchResults(selected, start, query, itemLimit, effectiveBytes, options);
1168
+ const { results, omissions, next } = await collectRepositorySearchResults(selected, symbolStart, query, itemLimit, effectiveBytes, options);
1169
+ const fallbackReason = start.mode === "symbol-only" ? undefined : textFallbackReason(results, query);
1170
+ let textFallbackTruncated = false;
1171
+ let fallbackCursor;
1172
+ if (fallbackReason) {
1173
+ const textMatches = [];
1174
+ const eligible = selected.filter(({ health }) => health === "healthy");
1175
+ const inspected = eligible.slice(0, 25);
1176
+ if (inspected.length < eligible.length)
1177
+ textFallbackTruncated = true;
1178
+ for (const inventory of inspected) {
1179
+ const text = trackedTextMatches(inventory, query, options.exposePaths);
1180
+ textFallbackTruncated ||= text.truncated;
1181
+ textMatches.push(...text.matches);
1182
+ if (textMatches.length >= TRACKED_TEXT_CANDIDATE_LIMIT) {
1183
+ textFallbackTruncated = true;
1184
+ break;
1185
+ }
1186
+ }
1187
+ textMatches.sort((left, right) => right.confidence - left.confidence ||
1188
+ compareBytes(`${left.repositoryId}:${left.file}:${left.line ?? 0}`, `${right.repositoryId}:${right.file}:${right.line ?? 0}`));
1189
+ const combined = textMatches.slice(0, TRACKED_TEXT_CANDIDATE_LIMIT);
1190
+ const fallbackOffset = start.mode === "fallback" ? start.offset : 0;
1191
+ const page = [];
1192
+ let nextOffset = fallbackOffset;
1193
+ while (nextOffset < combined.length && page.length < itemLimit) {
1194
+ const candidate = combined[nextOffset];
1195
+ if (!candidate)
1196
+ break;
1197
+ const probe = JSON.stringify({
1198
+ results: [...page, candidate],
1199
+ omissions,
1200
+ repositories: selected,
1201
+ });
1202
+ if (Buffer.byteLength(probe) > effectiveBytes && page.length > 0)
1203
+ break;
1204
+ page.push(candidate);
1205
+ nextOffset += 1;
1206
+ if (Buffer.byteLength(probe) > effectiveBytes)
1207
+ break;
1208
+ }
1209
+ results.splice(0, results.length, ...page);
1210
+ if (nextOffset < combined.length)
1211
+ fallbackCursor = encodeCursor({
1212
+ hash,
1213
+ repository: 0,
1214
+ offset: nextOffset,
1215
+ mode: "fallback",
1216
+ });
1217
+ else if (results.length > 0 || next)
1218
+ fallbackCursor = encodeCursor({ hash, repository: 0, offset: 0, mode: "symbol-only" });
1219
+ }
1025
1220
  if (omissions.length > 0 && options.allowPartial !== true) {
1026
1221
  return finalizeRepositorySearch({
1027
1222
  schemaVersion: 1,
@@ -1040,7 +1235,28 @@ export async function searchRepositories(roots, query, options) {
1040
1235
  results,
1041
1236
  repositories: selected,
1042
1237
  omissions,
1043
- ...(next ? { cursor: encodeCursor({ hash, ...next }) } : {}),
1238
+ ...(fallbackReason
1239
+ ? {
1240
+ retrieval: {
1241
+ textFallback: true,
1242
+ reason: fallbackReason,
1243
+ inspectedRepositories: Math.min(25, selected.filter(({ health }) => health === "healthy").length),
1244
+ candidateLimit: TRACKED_TEXT_CANDIDATE_LIMIT,
1245
+ truncated: textFallbackTruncated,
1246
+ },
1247
+ }
1248
+ : {}),
1249
+ ...(fallbackCursor
1250
+ ? { cursor: fallbackCursor }
1251
+ : !fallbackReason && next
1252
+ ? {
1253
+ cursor: encodeCursor({
1254
+ hash,
1255
+ ...next,
1256
+ mode: start.mode === "symbol-only" ? "symbol-only" : "symbol",
1257
+ }),
1258
+ }
1259
+ : {}),
1044
1260
  };
1045
1261
  return finalizeRepositorySearch(base, byteLimit, tokenLimit, itemLimit);
1046
1262
  }
@@ -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
  }