knodin 0.8.6 → 0.9.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.
Files changed (67) hide show
  1. package/README.md +6 -0
  2. package/dist/bin/cli.js +459 -95
  3. package/dist/bin/launcher.js +0 -0
  4. package/dist/fixtures/update-verification/config/application.xml +4 -0
  5. package/dist/fixtures/update-verification/dbt/manifest.json +17 -0
  6. package/dist/fixtures/update-verification/dbt/models/accounts.sql +1 -0
  7. package/dist/fixtures/update-verification/dbt/models/contacts.sql +1 -0
  8. package/dist/fixtures/update-verification/fixture.json +147 -0
  9. package/dist/fixtures/update-verification/force-app/main/default/classes/UpdateFixture.cls +14 -0
  10. package/dist/fixtures/update-verification/force-app/main/default/flows/Update_Verification.flow-meta.xml +9 -0
  11. package/dist/fixtures/update-verification/package.json +5 -0
  12. package/dist/fixtures/update-verification/prisma/schema.prisma +10 -0
  13. package/dist/fixtures/update-verification/sql/functions.sql +16 -0
  14. package/dist/fixtures/update-verification/src/CsharpFixture.cs +6 -0
  15. package/dist/fixtures/update-verification/src/JavaFixture.java +9 -0
  16. package/dist/fixtures/update-verification/src/javascript.js +7 -0
  17. package/dist/fixtures/update-verification/src/python_fixture.py +6 -0
  18. package/dist/fixtures/update-verification/src/typescript.ts +7 -0
  19. package/dist/fixtures/update-verification/terraform/main.tf +7 -0
  20. package/dist/fixtures/update-verification/terraform/modules/verified-child/main.tf +3 -0
  21. package/dist/src/agent-integration.js +26 -3
  22. package/dist/src/backup-retention.js +811 -0
  23. package/dist/src/cli-model.js +66 -9
  24. package/dist/src/docs-sections.js +1 -0
  25. package/dist/src/doctor.js +142 -12
  26. package/dist/src/engine/embeddings.js +8 -1
  27. package/dist/src/engine/index.js +355 -292
  28. package/dist/src/init.js +223 -164
  29. package/dist/src/manager-update.js +455 -0
  30. package/dist/src/release-preflight.js +106 -95
  31. package/dist/src/repair-lease.js +77 -8
  32. package/dist/src/repository-management.js +219 -3
  33. package/dist/src/response-budget.js +5 -1
  34. package/dist/src/server.js +18 -5
  35. package/dist/src/shared-index/compatibility.js +4 -2
  36. package/dist/src/skill-management.js +149 -0
  37. package/dist/src/system-config.js +176 -16
  38. package/dist/src/system-management.js +508 -0
  39. package/dist/src/tools/knodin-tools.js +174 -22
  40. package/dist/src/update-ceremony.js +20 -24
  41. package/dist/src/update-coordination.js +158 -0
  42. package/dist/src/update-executor.js +336 -0
  43. package/dist/src/update-verifier.js +358 -0
  44. package/docs/BACKUP-RETENTION.md +54 -0
  45. package/docs/CLI.md +13 -0
  46. package/docs/DOCTOR-AND-UPDATES.md +44 -12
  47. package/docs/SIGNED-UPDATES.md +29 -22
  48. package/docs/SYSTEMS-AND-RELATIONSHIPS.md +27 -6
  49. package/docs/prompts/declare-multi-repository-system.md +18 -0
  50. package/docs/releases/0.8.7.md +27 -0
  51. package/docs/releases/0.9.0.md +43 -0
  52. package/package.json +11 -2
  53. package/roadmap/competitive-roadmap.md +8 -0
  54. package/skills/knodin-debug/SKILL.md +12 -0
  55. package/skills/knodin-debug/agents/openai.yaml +4 -0
  56. package/skills/knodin-explore/SKILL.md +11 -0
  57. package/skills/knodin-explore/agents/openai.yaml +4 -0
  58. package/skills/knodin-impact/SKILL.md +11 -0
  59. package/skills/knodin-impact/agents/openai.yaml +4 -0
  60. package/skills/knodin-lifecycle/SKILL.md +12 -0
  61. package/skills/knodin-lifecycle/agents/openai.yaml +4 -0
  62. package/skills/knodin-refactor/SKILL.md +12 -0
  63. package/skills/knodin-refactor/agents/openai.yaml +4 -0
  64. package/skills/knodin-review/SKILL.md +11 -0
  65. package/skills/knodin-review/agents/openai.yaml +4 -0
  66. package/skills/knodin-systems/SKILL.md +14 -0
  67. 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 { 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
  }
@@ -42,9 +42,11 @@ const PROTECTED_CONTRACT_KEYS = new Set([
42
42
  "operation",
43
43
  "kind",
44
44
  "name",
45
+ "client",
45
46
  "target",
46
47
  "to",
47
48
  ]);
49
+ const PROTECTED_CONTRACT_ARRAY_PATHS = new Set(["$/agents/clients"]);
48
50
  function isTruncatableDetail(entry) {
49
51
  return (!entry.path.startsWith("$/sharedIndex/") &&
50
52
  (typeof entry.key !== "string" || !PROTECTED_CONTRACT_KEYS.has(entry.key)));
@@ -105,7 +107,9 @@ function truncatePayloadToBytes(root, byteLimit) {
105
107
  }
106
108
  while (bytes(root) > byteLimit) {
107
109
  const candidate = collectArrays(root)
108
- .filter((entry) => !entry.path.startsWith("$/responseBudget") && entry.value.length > 0)
110
+ .filter((entry) => !entry.path.startsWith("$/responseBudget") &&
111
+ !PROTECTED_CONTRACT_ARRAY_PATHS.has(entry.path) &&
112
+ entry.value.length > 0)
109
113
  .sort((a, b) => bytes(b.value) - bytes(a.value))[0];
110
114
  if (!candidate)
111
115
  break;
@@ -23,6 +23,7 @@ const RETRY_SAFE_OPERATIONS = new Set([
23
23
  "explain",
24
24
  "map",
25
25
  "query",
26
+ "query_batch",
26
27
  "review",
27
28
  "search",
28
29
  "status",
@@ -69,7 +70,9 @@ export function recordMcpDiagnosticFailure(args, error, defaultRepo = process.cw
69
70
  });
70
71
  const message = error instanceof Error ? error.message : String(error);
71
72
  return diagnostic.recorded
72
- ? new Error(`${message} [diagnostic ${diagnostic.correlationId}]`, { cause: error })
73
+ ? new Error(`${message} [diagnostic ${diagnostic.correlationId}]`, {
74
+ cause: error,
75
+ })
73
76
  : error;
74
77
  }
75
78
  export function createServer(dispatch = handleKnodinTool) {
@@ -111,7 +114,11 @@ export function createServer(dispatch = handleKnodinTool) {
111
114
  void extra
112
115
  .sendNotification({
113
116
  method: "notifications/progress",
114
- params: { progressToken, progress: sequence, message: phase },
117
+ params: {
118
+ progressToken,
119
+ progress: sequence,
120
+ message: phase,
121
+ },
115
122
  })
116
123
  .catch(ignoreNotificationFailure);
117
124
  })
@@ -121,7 +128,9 @@ export function createServer(dispatch = handleKnodinTool) {
121
128
  if (progressToken === undefined)
122
129
  return;
123
130
  progressSequence++;
124
- recordMcpLifecycle(context, "progress", { sequence: progressSequence });
131
+ recordMcpLifecycle(context, "progress", {
132
+ sequence: progressSequence,
133
+ });
125
134
  await extra.sendNotification({
126
135
  method: "notifications/progress",
127
136
  params: {
@@ -143,7 +152,9 @@ export function createServer(dispatch = handleKnodinTool) {
143
152
  }, { cooperativeCancellation: operation === "repair" })),
144
153
  };
145
154
  const result = execution.result;
146
- recordMcpLifecycle(context, "success", { elapsedMs: Date.now() - startedAt });
155
+ recordMcpLifecycle(context, "success", {
156
+ elapsedMs: Date.now() - startedAt,
157
+ });
147
158
  return {
148
159
  _meta: {
149
160
  requestId: context.requestId,
@@ -192,7 +203,9 @@ export function createServer(dispatch = handleKnodinTool) {
192
203
  finally {
193
204
  if (heartbeat)
194
205
  clearInterval(heartbeat);
195
- recordMcpLifecycle(context, "cleanup", { elapsedMs: Date.now() - startedAt });
206
+ recordMcpLifecycle(context, "cleanup", {
207
+ elapsedMs: Date.now() - startedAt,
208
+ });
196
209
  }
197
210
  });
198
211
  return server;
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { EMBEDDING_DIMENSIONS, EMBEDDING_MODEL_ID, EMBEDDING_MODEL_REVISION, } from "../engine/embeddings.js";
5
+ import { EMBEDDING_DIMENSIONS, EMBEDDING_MODEL_DTYPE, EMBEDDING_MODEL_ID, EMBEDDING_MODEL_REVISION, } from "../engine/embeddings.js";
6
6
  import { KNODIN_SCHEMA_VERSION } from "../engine/index.js";
7
7
  import { prunePolicyDescriptor } from "../engine/prune.js";
8
8
  import { KNODIN_VERSION } from "../version.js";
@@ -44,6 +44,7 @@ export function currentSharedIndexCompatibility() {
44
44
  id: EMBEDDING_MODEL_ID,
45
45
  revision: EMBEDDING_MODEL_REVISION,
46
46
  dimensions: EMBEDDING_DIMENSIONS,
47
+ dtype: EMBEDDING_MODEL_DTYPE,
47
48
  runtime: `@huggingface/transformers@${packageVersion("@huggingface/transformers")}`,
48
49
  };
49
50
  const parsers = {
@@ -61,9 +62,10 @@ export function currentSharedIndexCompatibility() {
61
62
  modelDigest: digest(model),
62
63
  parserBundleDigest: digest(parsers),
63
64
  indexConfigDigest: digest({
64
- policyVersion: 1,
65
+ policyVersion: 2,
65
66
  databaseSchemaVersion: KNODIN_SCHEMA_VERSION,
66
67
  embeddingDimensions: EMBEDDING_DIMENSIONS,
68
+ embeddingDtype: EMBEDDING_MODEL_DTYPE,
67
69
  }),
68
70
  prunePolicyDigest: digest(prunePolicyDescriptor()),
69
71
  };
@@ -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
+ }