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.
- package/README.md +6 -0
- package/dist/bin/cli.js +163 -15
- package/dist/bin/launcher.js +0 -0
- package/dist/src/cli-model.js +31 -1
- package/dist/src/engine/index.js +325 -54
- package/dist/src/init.js +16 -0
- package/dist/src/repository-management.js +219 -3
- package/dist/src/skill-management.js +149 -0
- package/dist/src/system-config.js +176 -16
- package/dist/src/system-management.js +508 -0
- package/dist/src/tools/knodin-tools.js +108 -15
- package/docs/CLI.md +7 -0
- package/docs/SYSTEMS-AND-RELATIONSHIPS.md +27 -6
- package/docs/prompts/declare-multi-repository-system.md +18 -0
- package/docs/releases/0.8.6.md +10 -0
- package/docs/releases/0.8.7.md +27 -0
- package/package.json +6 -2
- package/skills/knodin-debug/SKILL.md +12 -0
- package/skills/knodin-debug/agents/openai.yaml +4 -0
- package/skills/knodin-explore/SKILL.md +11 -0
- package/skills/knodin-explore/agents/openai.yaml +4 -0
- package/skills/knodin-impact/SKILL.md +11 -0
- package/skills/knodin-impact/agents/openai.yaml +4 -0
- package/skills/knodin-lifecycle/SKILL.md +12 -0
- package/skills/knodin-lifecycle/agents/openai.yaml +4 -0
- package/skills/knodin-refactor/SKILL.md +12 -0
- package/skills/knodin-refactor/agents/openai.yaml +4 -0
- package/skills/knodin-review/SKILL.md +11 -0
- package/skills/knodin-review/agents/openai.yaml +4 -0
- package/skills/knodin-systems/SKILL.md +14 -0
- package/skills/knodin-systems/agents/openai.yaml +4 -0
|
@@ -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 {
|
|
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,
|
|
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
|
-
...(
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
503
|
-
.
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
.
|
|
507
|
-
|
|
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
|
|
510
|
-
|
|
511
|
-
|
|
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
|
}
|