@kontextmind/kxm 0.6.0 → 0.7.10

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 (175) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/agents/coordinator.yaml +9 -0
  3. package/.kxm/agents/critic-arch.yaml +13 -0
  4. package/.kxm/agents/critic-cli.yaml +13 -0
  5. package/.kxm/agents/implementer.yaml +13 -0
  6. package/.kxm/gates.yaml +8 -0
  7. package/.kxm/producers.yaml +22 -0
  8. package/.kxm/project.yaml +15 -0
  9. package/.kxm/roles/writer.yaml +7 -0
  10. package/.kxm/workflows/default.yaml +47 -0
  11. package/CHANGELOG.md +39 -7
  12. package/README.md +1 -0
  13. package/docs/README.md +5 -0
  14. package/docs/adr/ADR-0002-browser-automation-steel-doks.md +103 -0
  15. package/docs/agent-skills.md +135 -0
  16. package/docs/architecture.md +1 -1
  17. package/docs/assignment-runner.md +21 -8
  18. package/docs/browser-automation.md +116 -0
  19. package/docs/configuration.md +11 -2
  20. package/docs/getting-started.md +21 -0
  21. package/docs/kb/how-credentials-retrieved-safely.md +31 -0
  22. package/docs/kb/how-to-capture-and-annotate-section.md +60 -0
  23. package/docs/kb/how-to-connect-playwright-to-steel.md +54 -0
  24. package/docs/kb/how-to-recover-expired-session-or-orphan.md +54 -0
  25. package/docs/kb/how-to-resume-after-mfa.md +28 -0
  26. package/docs/kb/how-to-take-over-session.md +32 -0
  27. package/docs/kb/why-authentication-disappeared.md +32 -0
  28. package/docs/kb/why-automation-opened-different-browser.md +32 -0
  29. package/docs/kb/why-session-viewer-cannot-control.md +31 -0
  30. package/docs/kxm-handbook.md +3 -3
  31. package/docs/operations.md +24 -0
  32. package/docs/operator-pi-packages.md +63 -0
  33. package/docs/prompts/browser-annotate-feedback.md +41 -0
  34. package/docs/prompts/browser-diagnose-recover.md +38 -0
  35. package/docs/prompts/browser-explore.md +42 -0
  36. package/docs/prompts/browser-repro-fix.md +48 -0
  37. package/docs/prompts/browser-start.md +41 -0
  38. package/docs/prompts/browser-takeover.md +50 -0
  39. package/docs/skills/repo-work-delivery.md +107 -0
  40. package/docs/skills.md +2 -0
  41. package/docs/test-matrix.md +4 -3
  42. package/docs/troubleshooting.md +41 -1
  43. package/docs/vnext/validation.md +9 -0
  44. package/docs/webhook-workflows.md +2 -2
  45. package/examples/README.md +1 -1
  46. package/package.json +16 -17
  47. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  48. package/plugins/kxm/README.md +1 -1
  49. package/plugins/kxm/dist/cli.js +41620 -35578
  50. package/plugins/kxm/dist/core.js +271 -34
  51. package/plugins/kxm/dist/extension.js +7759 -86
  52. package/plugins/kxm/dist/mcp-server.js +75 -21
  53. package/plugins/kxm/dist/runtime.js +8218 -2328
  54. package/plugins/kxm/dist/server.js +3125 -2260
  55. package/plugins/kxm/dist/vnext-runtime-supervisor.js +5961 -661
  56. package/plugins/kxm/package.json +1 -1
  57. package/plugins/kxm/skills/SUITE.md +5 -0
  58. package/plugins/kxm/skills/hints.json +103 -0
  59. package/plugins/kxm/skills/kxm/SKILL.md +30 -83
  60. package/plugins/kxm/skills/kxm-browser-annotate/SKILL.md +90 -0
  61. package/plugins/kxm/skills/kxm-browser-auth/SKILL.md +47 -0
  62. package/plugins/kxm/skills/kxm-browser-diagnostics/SKILL.md +48 -0
  63. package/plugins/kxm/skills/kxm-browser-explore/SKILL.md +48 -0
  64. package/plugins/kxm/skills/kxm-browser-session/SKILL.md +94 -0
  65. package/plugins/kxm/skills/kxm-browser-takeover/SKILL.md +87 -0
  66. package/plugins/kxm/skills/kxm-browser-verify/SKILL.md +71 -0
  67. package/plugins/kxm/skills/kxm-context-memory/SKILL.md +69 -0
  68. package/plugins/kxm/skills/kxm-definitions/SKILL.md +65 -0
  69. package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +34 -0
  70. package/plugins/kxm/skills/kxm-harvest/SKILL.md +48 -0
  71. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +43 -0
  72. package/plugins/kxm/skills/kxm-insights/SKILL.md +48 -0
  73. package/plugins/kxm/skills/kxm-mind/SKILL.md +59 -0
  74. package/plugins/kxm/skills/kxm-peer/SKILL.md +110 -0
  75. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +42 -0
  76. package/plugins/kxm/skills/kxm-projects/SKILL.md +43 -0
  77. package/plugins/kxm/skills/kxm-protocol/SKILL.md +66 -0
  78. package/plugins/kxm/skills/kxm-query/SKILL.md +45 -0
  79. package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +30 -0
  80. package/plugins/kxm/skills/kxm-runs/SKILL.md +29 -0
  81. package/plugins/kxm/skills/kxm-setup/SKILL.md +55 -0
  82. package/plugins/kxm/skills/kxm-skill-lifecycle/SKILL.md +31 -0
  83. package/plugins/kxm/skills/kxm-tasks/SKILL.md +33 -0
  84. package/plugins/kxm/skills/kxm-triage/SKILL.md +47 -0
  85. package/plugins/kxm/skills/kxm-work/SKILL.md +44 -0
  86. package/plugins/kxm/skills/kxm-workflow/SKILL.md +45 -0
  87. package/plugins/kxm/src/autocomplete.ts +9 -3
  88. package/plugins/kxm/src/browser.ts +603 -0
  89. package/plugins/kxm/src/cli/context-skills.ts +373 -0
  90. package/plugins/kxm/src/cli/hub.ts +614 -0
  91. package/plugins/kxm/src/cli/roles.ts +615 -0
  92. package/plugins/kxm/src/cli/system.ts +906 -0
  93. package/plugins/kxm/src/cli/tasks.ts +364 -0
  94. package/plugins/kxm/src/cli/types.ts +270 -0
  95. package/plugins/kxm/src/cli/vnext.ts +698 -0
  96. package/plugins/kxm/src/cli/workflows.ts +699 -0
  97. package/plugins/kxm/src/cli.ts +362 -2849
  98. package/plugins/kxm/src/commands.ts +150 -8
  99. package/plugins/kxm/src/completion-install.ts +223 -0
  100. package/plugins/kxm/src/config.ts +7 -4
  101. package/plugins/kxm/src/context-packet.ts +172 -0
  102. package/plugins/kxm/src/database.ts +1 -1
  103. package/plugins/kxm/src/extension.ts +36 -1
  104. package/plugins/kxm/src/external-effects.ts +357 -8
  105. package/plugins/kxm/src/hub-env.ts +193 -0
  106. package/plugins/kxm/src/hub.ts +2 -4
  107. package/plugins/kxm/src/improve.ts +72 -0
  108. package/plugins/kxm/src/init-guide-setup.ts +547 -0
  109. package/plugins/kxm/src/local-snapshot.ts +1 -1
  110. package/plugins/kxm/src/mcp-server.ts +1 -1
  111. package/plugins/kxm/src/model-inventory.ts +127 -0
  112. package/plugins/kxm/src/modes.ts +348 -0
  113. package/plugins/kxm/src/policy-draft.d.mts +55 -0
  114. package/plugins/kxm/src/policy-draft.mjs +565 -0
  115. package/plugins/kxm/src/price-calc.ts +17 -18
  116. package/plugins/kxm/src/prices.ts +32 -16
  117. package/plugins/kxm/src/producers.ts +71 -0
  118. package/plugins/kxm/src/protocol.ts +111 -0
  119. package/plugins/kxm/src/restricted-yaml.d.mts +31 -0
  120. package/plugins/kxm/src/restricted-yaml.mjs +145 -0
  121. package/plugins/kxm/src/role.ts +710 -0
  122. package/plugins/kxm/src/routing.ts +99 -1
  123. package/plugins/kxm/src/runtime.ts +4 -0
  124. package/plugins/kxm/src/safety-integrity.ts +76 -0
  125. package/plugins/kxm/src/session-work.ts +9 -2
  126. package/plugins/kxm/src/sqlite.ts +76 -0
  127. package/plugins/kxm/src/ssh-remote.ts +560 -0
  128. package/plugins/kxm/src/store.ts +1 -1
  129. package/plugins/kxm/src/studio-layout.ts +660 -17
  130. package/plugins/kxm/src/subagent-control.ts +312 -0
  131. package/plugins/kxm/src/suggest.ts +7 -13
  132. package/plugins/kxm/src/telemetry.ts +82 -0
  133. package/plugins/kxm/src/tui.ts +140 -0
  134. package/plugins/kxm/src/vnext-bindings.ts +1 -1
  135. package/plugins/kxm/src/vnext-config.ts +53 -111
  136. package/plugins/kxm/src/vnext-engine-command.ts +2 -0
  137. package/plugins/kxm/src/vnext-engine.ts +214 -62
  138. package/plugins/kxm/src/vnext-harness.ts +336 -84
  139. package/plugins/kxm/src/vnext-oneshot-evidence.ts +117 -0
  140. package/plugins/kxm/src/vnext-oneshot-process.ts +187 -0
  141. package/plugins/kxm/src/vnext-oneshot-producer.ts +182 -224
  142. package/plugins/kxm/src/vnext-pi-producer.ts +11 -7
  143. package/plugins/kxm/src/vnext-runtime-store.ts +36 -2
  144. package/plugins/kxm/src/vnext-runtime-supervisor.ts +122 -5
  145. package/plugins/kxm/src/vnext-runtime.ts +14 -0
  146. package/plugins/kxm/src/workflow-manager.ts +392 -0
  147. package/plugins/kxm/src/workflow-tui.ts +255 -0
  148. package/plugins/kxm/src/workflow.ts +144 -0
  149. package/schemas/policy-draft/README.md +17 -0
  150. package/schemas/policy-draft/model.v2.schema.json +140 -0
  151. package/schemas/policy-draft/role.v2.schema.json +91 -0
  152. package/schemas/vnext/modes.schema.json +56 -0
  153. package/schemas/vnext/role.schema.json +76 -0
  154. package/schemas/vnext/run-event.schema.json +1 -0
  155. package/scripts/assignment-run.d.mts +1 -1
  156. package/scripts/assignment-run.mjs +44 -35
  157. package/scripts/check-generated.mjs +33 -9
  158. package/scripts/emit-codex-artifacts.mjs +255 -11
  159. package/scripts/harness-run.d.mts +12 -4
  160. package/scripts/harness-run.mjs +65 -17
  161. package/scripts/kxm-bump-version.mjs +146 -0
  162. package/scripts/kxm-hub.mjs +150 -2
  163. package/scripts/kxm-publish-npm.mjs +3 -1
  164. package/scripts/kxm-release-github.mjs +3 -1
  165. package/scripts/kxm.mjs +0 -0
  166. package/scripts/native-critic.d.mts +5 -0
  167. package/scripts/native-critic.mjs +60 -0
  168. package/.kxm/config/README.md +0 -5
  169. package/.kxm/config/agents.json +0 -43
  170. package/.kxm/config/env.example +0 -56
  171. package/.kxm/config/update.example.yaml +0 -9
  172. package/.kxm/config/workflows/fix.json +0 -160
  173. package/.kxm/config/workflows/jira-development.json +0 -116
  174. package/.kxm/config/workflows/provenance-quorum.json +0 -150
  175. package/.kxm/config/workflows/v04-dogfood.json +0 -72
@@ -1,4 +1,7 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
2
5
  import { HubClient, HubHttpError } from "./client.ts";
3
6
  import type {
4
7
  DeliveryMode,
@@ -62,6 +65,7 @@ export interface SessionTokenPayload {
62
65
  agentName?: string;
63
66
  toolPolicy?: ToolPolicy;
64
67
  issuedAt: string;
68
+ expiresAt?: string;
65
69
  }
66
70
 
67
71
  export interface CommandExecutionContext {
@@ -930,6 +934,13 @@ export function parseAttemptToken(token: string): AttemptTokenPayload | undefine
930
934
  return undefined;
931
935
  }
932
936
 
937
+ export function timingSafeStringCompare(a: string | undefined, b: string | undefined): boolean {
938
+ if (typeof a !== "string" || typeof b !== "string") return false;
939
+ const hashA = createHash("sha256").update(a).digest();
940
+ const hashB = createHash("sha256").update(b).digest();
941
+ return timingSafeEqual(hashA, hashB);
942
+ }
943
+
933
944
  export function mintSessionToken(input?: {
934
945
  sessionId?: string;
935
946
  agentName?: string;
@@ -937,26 +948,53 @@ export function mintSessionToken(input?: {
937
948
  preset?: string;
938
949
  allowedTools?: string[];
939
950
  deniedTools?: string[];
951
+ expiresAt?: string;
952
+ ttlMs?: number;
940
953
  }): string {
941
954
  const toolPolicy: ToolPolicy | undefined = input?.toolPolicy ?? (
942
955
  input?.allowedTools || input?.deniedTools || input?.preset
943
956
  ? {
944
957
  ...(input?.preset ? { preset: input.preset } : {}),
945
- ...(input?.allowedTools ? { allow: input.allowedTools } : {}),
946
- ...(input?.deniedTools ? { deny: input.deniedTools } : {}),
958
+ ...(input?.allowedTools ? { allow: input.allowedTools, allowedTools: input.allowedTools } : {}),
959
+ ...(input?.deniedTools ? { deny: input.deniedTools, deniedTools: input.deniedTools } : {}),
947
960
  }
948
961
  : undefined
949
962
  );
963
+ const issuedAt = new Date().toISOString();
964
+ // Decision Q2: Default 24-hour TTL for disk SessionToken
965
+ const defaultTtlMs = 24 * 60 * 60 * 1000;
966
+ const ttlMs = typeof input?.ttlMs === "number" && input.ttlMs > 0 ? input.ttlMs : defaultTtlMs;
967
+ const expiresAt = input?.expiresAt ?? new Date(Date.now() + ttlMs).toISOString();
968
+
950
969
  const payload: SessionTokenPayload = {
951
970
  schema: "kxm.session-token.v1",
952
971
  sessionId: input?.sessionId ?? `session-${randomUUID()}`,
953
- issuedAt: new Date().toISOString(),
972
+ issuedAt,
973
+ expiresAt,
954
974
  ...(input?.agentName ? { agentName: input.agentName } : {}),
955
975
  ...(toolPolicy ? { toolPolicy } : {}),
956
976
  };
957
977
  return Buffer.from(JSON.stringify(payload)).toString("base64url");
958
978
  }
959
979
 
980
+ export function isSessionTokenExpired(payloadOrToken: SessionTokenPayload | string): boolean {
981
+ let payload: SessionTokenPayload | undefined;
982
+ if (typeof payloadOrToken === "string") {
983
+ try {
984
+ const raw = Buffer.from(payloadOrToken.trim(), "base64url").toString("utf8");
985
+ payload = JSON.parse(raw) as SessionTokenPayload;
986
+ } catch {
987
+ return true;
988
+ }
989
+ } else {
990
+ payload = payloadOrToken;
991
+ }
992
+ if (!payload || !payload.expiresAt) return false;
993
+ const expiryTime = new Date(payload.expiresAt).getTime();
994
+ if (Number.isNaN(expiryTime)) return true;
995
+ return Date.now() >= expiryTime;
996
+ }
997
+
960
998
  export function parseSessionToken(token: string): SessionTokenPayload | undefined {
961
999
  try {
962
1000
  const raw = Buffer.from(token.trim(), "base64url").toString("utf8");
@@ -967,6 +1005,12 @@ export function parseSessionToken(token: string): SessionTokenPayload | undefine
967
1005
  && parsed.schema === "kxm.session-token.v1"
968
1006
  && typeof parsed.sessionId === "string"
969
1007
  ) {
1008
+ if (parsed.expiresAt) {
1009
+ const expiryTime = new Date(parsed.expiresAt).getTime();
1010
+ if (!Number.isNaN(expiryTime) && Date.now() >= expiryTime) {
1011
+ return undefined; // Expired token fails closed
1012
+ }
1013
+ }
970
1014
  return parsed as SessionTokenPayload;
971
1015
  }
972
1016
  } catch {
@@ -975,6 +1019,61 @@ export function parseSessionToken(token: string): SessionTokenPayload | undefine
975
1019
  return undefined;
976
1020
  }
977
1021
 
1022
+ function resolveUserConfigDirectory(overrideDir?: string | undefined): string {
1023
+ if (overrideDir) return resolve(overrideDir);
1024
+ return resolve(process.env.KXM_USER_CONFIG_DIR?.trim() || join(homedir(), ".config", "kxm"));
1025
+ }
1026
+
1027
+ export function sessionTokenPath(userConfigDir?: string | undefined): string {
1028
+ return join(resolveUserConfigDirectory(userConfigDir), "session.token");
1029
+ }
1030
+
1031
+ export function persistSessionTokenToDisk(
1032
+ token: string,
1033
+ options?: { userConfigDir?: string | undefined; mode?: number | undefined } | undefined,
1034
+ ): string {
1035
+ const filePath = sessionTokenPath(options?.userConfigDir);
1036
+ const dir = dirname(filePath);
1037
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
1038
+ const mode = options?.mode ?? 0o600;
1039
+ writeFileSync(filePath, `${token.trim()}\n`, { encoding: "utf8", mode });
1040
+ try {
1041
+ chmodSync(filePath, mode);
1042
+ } catch {
1043
+ // Ignore permissions error on filesystems that do not support POSIX modes
1044
+ }
1045
+ return filePath;
1046
+ }
1047
+
1048
+ export function readSessionTokenFromDisk(options?: {
1049
+ userConfigDir?: string | undefined;
1050
+ } | undefined): { token: string; payload: SessionTokenPayload } | undefined {
1051
+ const filePath = sessionTokenPath(options?.userConfigDir);
1052
+ if (!existsSync(filePath)) return undefined;
1053
+ try {
1054
+ const token = readFileSync(filePath, "utf8").trim();
1055
+ if (!token) return undefined;
1056
+ const payload = parseSessionToken(token);
1057
+ if (!payload) return undefined;
1058
+ return { token, payload };
1059
+ } catch {
1060
+ return undefined;
1061
+ }
1062
+ }
1063
+
1064
+ export function clearSessionTokenFromDisk(options?: {
1065
+ userConfigDir?: string | undefined;
1066
+ } | undefined): boolean {
1067
+ const filePath = sessionTokenPath(options?.userConfigDir);
1068
+ if (!existsSync(filePath)) return false;
1069
+ try {
1070
+ unlinkSync(filePath);
1071
+ return true;
1072
+ } catch {
1073
+ return false;
1074
+ }
1075
+ }
1076
+
978
1077
  function matchToolPattern(pattern: string, toolName: string): boolean {
979
1078
  if (pattern === "*" || pattern === toolName) return true;
980
1079
  if (pattern.endsWith("*")) {
@@ -1026,6 +1125,7 @@ export function isToolAllowed(commandName: string, policy?: ToolPolicy): boolean
1026
1125
  export function enforceToolPolicy(
1027
1126
  commandName: string,
1028
1127
  env: NodeJS.ProcessEnv = process.env,
1128
+ options?: { runId?: string; stageId?: string },
1029
1129
  ): { allowed: boolean; error?: string; detail?: string } {
1030
1130
  const attemptTokenRaw = env.KXM_ATTEMPT_TOKEN?.trim();
1031
1131
  if (attemptTokenRaw) {
@@ -1040,14 +1140,56 @@ export function enforceToolPolicy(
1040
1140
  detail: `command ${commandName} is denied by attempt tool policy`,
1041
1141
  };
1042
1142
  }
1143
+ // AttemptToken boundary: AttemptTokens are strictly worker tokens.
1144
+ // They cannot execute administrative state promotion unless explicitly allowed in toolPolicy
1145
+ if (commandName === "kxm_promote" || commandName === "promote") {
1146
+ const explicitAllow = attempt.toolPolicy?.allow ?? attempt.toolPolicy?.allowedTools;
1147
+ if (!Array.isArray(explicitAllow) || (!explicitAllow.includes("kxm_promote") && !explicitAllow.includes("promote") && !explicitAllow.includes("*"))) {
1148
+ return {
1149
+ allowed: false,
1150
+ error: "attempt_token_admin_denied",
1151
+ detail: "AttemptToken worker cannot perform operator state promotion without explicit policy grant",
1152
+ };
1153
+ }
1154
+ }
1155
+ // AttemptToken boundary: Scoped strictly to runId if provided in execution options
1156
+ if (options?.runId && attempt.runId && options.runId !== attempt.runId) {
1157
+ return {
1158
+ allowed: false,
1159
+ error: "attempt_token_scope_violation",
1160
+ detail: `attempt token runId ${attempt.runId} does not match request runId ${options.runId}`,
1161
+ };
1162
+ }
1163
+ return { allowed: true };
1164
+ }
1165
+
1166
+ const envSessionRaw = env.KXM_SESSION_TOKEN?.trim();
1167
+ if (envSessionRaw) {
1168
+ const session = parseSessionToken(envSessionRaw);
1169
+ if (!session) {
1170
+ return { allowed: false, error: "session_token_invalid", detail: "KXM_SESSION_TOKEN is malformed or expired" };
1171
+ }
1172
+ if (!isToolAllowed(commandName, session.toolPolicy)) {
1173
+ return {
1174
+ allowed: false,
1175
+ error: "tool_policy_denied",
1176
+ detail: `command ${commandName} is denied by session tool policy`,
1177
+ };
1178
+ }
1043
1179
  return { allowed: true };
1044
1180
  }
1045
1181
 
1046
- const sessionTokenRaw = env.KXM_SESSION_TOKEN?.trim();
1047
- if (sessionTokenRaw) {
1048
- const session = parseSessionToken(sessionTokenRaw);
1182
+ const tokenFile = sessionTokenPath(env.KXM_USER_CONFIG_DIR);
1183
+ if (existsSync(tokenFile)) {
1184
+ let tokenRaw: string | undefined;
1185
+ try {
1186
+ tokenRaw = readFileSync(tokenFile, "utf8").trim();
1187
+ } catch {
1188
+ return { allowed: false, error: "session_token_invalid", detail: "Session token file on disk could not be read" };
1189
+ }
1190
+ const session = tokenRaw ? parseSessionToken(tokenRaw) : undefined;
1049
1191
  if (!session) {
1050
- return { allowed: false, error: "session_token_invalid", detail: "KXM_SESSION_TOKEN is malformed" };
1192
+ return { allowed: false, error: "session_token_invalid", detail: "Session token on disk is malformed or expired" };
1051
1193
  }
1052
1194
  if (!isToolAllowed(commandName, session.toolPolicy)) {
1053
1195
  return {
@@ -0,0 +1,223 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { generateShellCompletion, type SupportedShell } from "./autocomplete.ts";
5
+
6
+ export const COMPLETION_MARKER = "# kxm completion";
7
+ export const PATH_MARKER = "# kxm path";
8
+ export interface CompletionInstallOptions {
9
+ env?: NodeJS.ProcessEnv | undefined;
10
+ homeDir?: string | undefined;
11
+ configDir?: string | undefined;
12
+ platform?: NodeJS.Platform | undefined;
13
+ isTty?: boolean | undefined;
14
+ shell?: string | undefined;
15
+ dryRun?: boolean | undefined;
16
+ overwrite?: boolean | undefined;
17
+ }
18
+
19
+ export interface CompletionInstallReport {
20
+ ok: boolean;
21
+ shell: SupportedShell | "unknown";
22
+ scriptPath: string;
23
+ rcFile: string | undefined;
24
+ rcModified: boolean;
25
+ alreadyInstalled: boolean;
26
+ reason?: string | undefined;
27
+ }
28
+
29
+ export function detectShell(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): SupportedShell | "unknown" {
30
+ const shellPath = env.SHELL?.trim() || (process.platform === "win32" ? undefined : env.SHELL?.trim());
31
+ const name = shellPath ? basename(shellPath).toLowerCase() : "";
32
+ if (name === "bash" || name === "zsh" || name === "fish") return name;
33
+ if (name.endsWith("bash") || name.includes("bash")) return "bash";
34
+ if (name.endsWith("zsh")) return "zsh";
35
+ if (name.endsWith("fish")) return "fish";
36
+ return "unknown";
37
+ }
38
+
39
+ function effectiveHome(options: Pick<CompletionInstallOptions, "env" | "homeDir">): string {
40
+ const env = options.env ?? process.env;
41
+ if (options.homeDir) return options.homeDir;
42
+ const envHome = env.HOME?.trim() ?? env.USERPROFILE?.trim();
43
+ return envHome && envHome.length > 0 ? resolve(envHome) : homedir();
44
+ }
45
+
46
+ export function completionScriptPath(shell: SupportedShell, options: Pick<CompletionInstallOptions, "env" | "homeDir" | "configDir"> = {}): string {
47
+ const env = options.env ?? process.env;
48
+ const explicit = options.configDir ?? env.KXM_USER_CONFIG_DIR?.trim();
49
+ const base = explicit && explicit.length > 0 ? resolve(explicit) : resolve(effectiveHome(options), ".config", "kxm");
50
+ return join(base, "completions", `kxm.${shell}`);
51
+ }
52
+
53
+ function bashRcCandidate(options: Pick<CompletionInstallOptions, "env" | "homeDir">): string | undefined {
54
+ const home = effectiveHome(options);
55
+ const candidates = [join(home, ".bashrc"), join(home, ".bash_profile")];
56
+ const existing = candidates.find((candidate) => existsSync(candidate));
57
+ return existing ?? candidates[0];
58
+ }
59
+
60
+ function zshRcCandidate(options: Pick<CompletionInstallOptions, "env" | "homeDir">): string | undefined {
61
+ const env = options.env ?? process.env;
62
+ const home = effectiveHome(options);
63
+ if (env.ZDOTDIR?.trim()) return join(resolve(env.ZDOTDIR.trim()), ".zshrc");
64
+ return join(home, ".zshrc");
65
+ }
66
+
67
+ function fishCompletionTarget(shell: SupportedShell, options: Pick<CompletionInstallOptions, "env" | "homeDir">): string {
68
+ const env = options.env ?? process.env;
69
+ const home = effectiveHome(options);
70
+ if (env.XDG_CONFIG_HOME?.trim()) return join(resolve(env.XDG_CONFIG_HOME.trim()), "fish", "completions", "kxm.fish");
71
+ return join(home, ".config", "fish", "completions", "kxm.fish");
72
+ }
73
+
74
+ export function completionRcTarget(shell: SupportedShell, options: Pick<CompletionInstallOptions, "env" | "homeDir"> = {}): { rcFile?: string | undefined } {
75
+ if (shell === "fish") {
76
+ return { rcFile: undefined }; // fish auto-loads ~/.config/fish/completions
77
+ }
78
+ if (shell === "zsh") {
79
+ return { rcFile: zshRcCandidate(options) };
80
+ }
81
+ return { rcFile: bashRcCandidate(options) };
82
+ }
83
+
84
+ function sourceLine(shell: SupportedShell, scriptPath: string): string {
85
+ if (shell === "fish") return `source ${scriptPath}`;
86
+ if (shell === "zsh") return `[[ -f ${scriptPath} ]] && source ${scriptPath}`;
87
+ return `[[ -f ${scriptPath} ]] && source ${scriptPath}`;
88
+ }
89
+
90
+ export function installShellCompletion(
91
+ shellInput: string | undefined,
92
+ options: CompletionInstallOptions = {},
93
+ ): CompletionInstallReport {
94
+ const shell = shellInput?.trim() && shellInput !== "auto" ? shellInput.trim() as SupportedShell : detectShell(options.env ?? process.env, options.platform ?? process.platform);
95
+ if (shell !== "bash" && shell !== "zsh" && shell !== "fish") {
96
+ return {
97
+ ok: false,
98
+ shell: "unknown",
99
+ scriptPath: "",
100
+ rcFile: undefined,
101
+ rcModified: false,
102
+ alreadyInstalled: false,
103
+ reason: "shell_not_detected",
104
+ };
105
+ }
106
+
107
+ const scriptPath = completionScriptPath(shell, options);
108
+ const { rcFile } = completionRcTarget(shell, options);
109
+ const script = generateShellCompletion(shell);
110
+
111
+ const existingScript = existsSync(scriptPath);
112
+ const existingScriptMatches = existingScript && readFileSync(scriptPath, "utf8") === script;
113
+
114
+ let rcModified = false;
115
+ let alreadyInstalled = false;
116
+
117
+ if (shell === "fish") {
118
+ // fish auto-loads files in ~/.config/fish/completions by filename; no rc edit needed
119
+ const fishTarget = fishCompletionTarget(shell, options);
120
+ const fishInstalled = existsSync(fishTarget) && readFileSync(fishTarget, "utf8") === script;
121
+ alreadyInstalled = fishInstalled;
122
+ if (!options.dryRun && (!fishInstalled || options.overwrite)) {
123
+ mkdirSync(resolve(fishTarget, ".."), { recursive: true });
124
+ writeFileSync(fishTarget, script, { encoding: "utf8" });
125
+ }
126
+ return {
127
+ ok: true,
128
+ shell,
129
+ scriptPath: fishTarget,
130
+ rcFile: undefined,
131
+ rcModified: !fishInstalled,
132
+ alreadyInstalled,
133
+ };
134
+ }
135
+
136
+ if (rcFile) {
137
+ const line = sourceLine(shell, scriptPath);
138
+ const rcContent = existsSync(rcFile) ? readFileSync(rcFile, "utf8") : "";
139
+ alreadyInstalled = rcContent.includes(line) || rcContent.includes(`${COMPLETION_MARKER}`);
140
+ }
141
+
142
+ if (!options.dryRun) {
143
+ if (!existingScriptMatches || options.overwrite) {
144
+ mkdirSync(resolve(scriptPath, ".."), { recursive: true });
145
+ writeFileSync(scriptPath, script, { encoding: "utf8" });
146
+ }
147
+ if (rcFile && !alreadyInstalled) {
148
+ const line = sourceLine(shell, scriptPath);
149
+ const marker = `${COMPLETION_MARKER} (${new Date().toISOString()})`;
150
+ const stanza = `\n${marker}\n${line}\n`;
151
+ mkdirSync(resolve(rcFile, ".."), { recursive: true });
152
+ writeFileSync(rcFile, (existsSync(rcFile) ? readFileSync(rcFile, "utf8") : "") + stanza, { encoding: "utf8" });
153
+ rcModified = true;
154
+ }
155
+ }
156
+
157
+ return {
158
+ ok: true,
159
+ shell,
160
+ scriptPath,
161
+ rcFile,
162
+ rcModified,
163
+ alreadyInstalled,
164
+ };
165
+ }
166
+
167
+ export interface PathInstallReport {
168
+ ok: boolean;
169
+ binDir: string | undefined;
170
+ rcFile: string | undefined;
171
+ rcModified: boolean;
172
+ alreadyInstalled: boolean;
173
+ reason?: string | undefined;
174
+ }
175
+
176
+ /** Locate the directory containing the kxm entrypoint (global npm bin or clone scripts dir). */
177
+ export function kxmBinDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
178
+ const argv1 = env.KXM_ENTRY ?? process.argv[1];
179
+ if (argv1 && isAbsolute(argv1)) {
180
+ const dir = dirname(argv1);
181
+ if (existsSync(join(dir, process.platform === "win32" ? "kxm.cmd" : "kxm"))) return dir;
182
+ }
183
+ // fall back to PATH lookup
184
+ const pathEnv = env.PATH ?? "";
185
+ for (const part of pathEnv.split(delimiter)) {
186
+ if (!part) continue;
187
+ const candidate = resolve(part, process.platform === "win32" ? "kxm.cmd" : "kxm");
188
+ if (existsSync(candidate)) return resolve(part);
189
+ }
190
+ return undefined;
191
+ }
192
+
193
+ export function kxmOnPath(env: NodeJS.ProcessEnv = process.env): boolean {
194
+ return kxmBinDir(env) !== undefined;
195
+ }
196
+
197
+ function pathLine(binDir: string): string {
198
+ return `export PATH="${binDir}:$PATH" # kxm`;
199
+ }
200
+
201
+ /** Append a PATH export for the kxm bin dir to the shell rc (bash/zsh). */
202
+ export function installPathEntry(
203
+ shellInput: string | undefined,
204
+ options: CompletionInstallOptions & { binDir?: string } = {},
205
+ ): PathInstallReport {
206
+ const shell = shellInput?.trim() && shellInput !== "auto" ? shellInput.trim() as SupportedShell : detectShell(options.env ?? process.env, options.platform ?? process.platform);
207
+ if (shell !== "bash" && shell !== "zsh") {
208
+ return { ok: false, binDir: undefined, rcFile: undefined, rcModified: false, alreadyInstalled: false, reason: "shell_not_supported" };
209
+ }
210
+ const binDir = options.binDir ?? kxmBinDir(options.env ?? process.env);
211
+ if (!binDir) {
212
+ return { ok: false, binDir: undefined, rcFile: undefined, rcModified: false, alreadyInstalled: false, reason: "kxm_not_found" };
213
+ }
214
+ const { rcFile } = completionRcTarget(shell, options);
215
+ const line = pathLine(binDir);
216
+ const rcContent = existsSync(rcFile ?? "") ? readFileSync(rcFile!, "utf8") : "";
217
+ const alreadyInstalled = rcContent.includes(binDir) || (options.env?.PATH ?? process.env.PATH ?? "").split(delimiter).includes(binDir);
218
+ if (!options.dryRun && rcFile && !alreadyInstalled) {
219
+ mkdirSync(dirname(rcFile), { recursive: true });
220
+ writeFileSync(rcFile, rcContent + `\n${PATH_MARKER}\n${line}\n`, { encoding: "utf8" });
221
+ }
222
+ return { ok: true, binDir, rcFile, rcModified: !alreadyInstalled && !options.dryRun, alreadyInstalled };
223
+ }
@@ -193,8 +193,8 @@ export function loadKxmConfig(
193
193
  const text = readFileSync(userConfigFile, "utf8");
194
194
  userRaw = (parse(text) as Record<string, unknown>) ?? {};
195
195
  userLoadedPath = userConfigFile;
196
- } catch {
197
- // ignore parse error, fallback
196
+ } catch (error) {
197
+ throw new Error(`invalid user config YAML at ${userConfigFile}`, { cause: error });
198
198
  }
199
199
  }
200
200
 
@@ -205,8 +205,8 @@ export function loadKxmConfig(
205
205
  const text = readFileSync(repoConfigFile, "utf8");
206
206
  repoRaw = (parse(text) as Record<string, unknown>) ?? {};
207
207
  repoLoadedPath = repoConfigFile;
208
- } catch {
209
- // ignore parse error, fallback
208
+ } catch (error) {
209
+ throw new Error(`invalid project config YAML at ${repoConfigFile}`, { cause: error });
210
210
  }
211
211
  }
212
212
 
@@ -264,6 +264,9 @@ export function setKxmConfigValue(
264
264
  }
265
265
 
266
266
  const parts = keyPath.split(".");
267
+ if (parts.some((part) => !part || part === "__proto__" || part === "prototype" || part === "constructor")) {
268
+ throw new Error("invalid config key path");
269
+ }
267
270
  let cursor = existing;
268
271
  for (let i = 0; i < parts.length - 1; i++) {
269
272
  const p = parts[i]!;
@@ -291,6 +291,178 @@ export function formatContextPacketForPrompt(packet: FormalContextPacketV2): str
291
291
  return sections.join("\n").trim();
292
292
  }
293
293
 
294
+ export interface PruningAudit {
295
+ initialTokens: number;
296
+ finalTokens: number;
297
+ budgetTokens: number;
298
+ prunedStages: Array<"L1_defaults" | "L2_episodes" | "L2_docs" | "L5_artifacts">;
299
+ prunedItemCounts: {
300
+ sharedDefaults: number;
301
+ episodes: number;
302
+ docs: number;
303
+ artifactSnippets: number;
304
+ };
305
+ unresolvedGaps: string[];
306
+ }
307
+
308
+ export function estimateContextPacketTokens(packet: FormalContextPacketV2): number {
309
+ const prompt = formatContextPacketForPrompt(packet);
310
+ return Math.ceil(prompt.length / 4);
311
+ }
312
+
313
+ /**
314
+ * Deterministic Pruning Hierarchy (Decision Q4):
315
+ * L1 Defaults -> L2 Historical Episodes -> L2 Project Docs -> L5 Artifacts; L3 Task Objective is inviolable.
316
+ */
317
+ export function pruneContextPacket(
318
+ packet: FormalContextPacketV2,
319
+ tokenBudget?: number,
320
+ ): { packet: FormalContextPacketV2; audit: PruningAudit } {
321
+ const budget = tokenBudget ?? packet.budget.allocatedTokens;
322
+ let currentTokens = estimateContextPacketTokens(packet);
323
+ const initialTokens = currentTokens;
324
+
325
+ const prunedStages: Array<"L1_defaults" | "L2_episodes" | "L2_docs" | "L5_artifacts"> = [];
326
+ const prunedItemCounts = {
327
+ sharedDefaults: 0,
328
+ episodes: 0,
329
+ docs: 0,
330
+ artifactSnippets: 0,
331
+ };
332
+ const unresolvedGaps: string[] = [...packet.budget.unresolvedGaps];
333
+
334
+ if (currentTokens <= budget) {
335
+ return {
336
+ packet: {
337
+ ...packet,
338
+ budget: {
339
+ ...packet.budget,
340
+ allocatedTokens: budget,
341
+ estimatedTokens: currentTokens,
342
+ },
343
+ },
344
+ audit: {
345
+ initialTokens,
346
+ finalTokens: currentTokens,
347
+ budgetTokens: budget,
348
+ prunedStages,
349
+ prunedItemCounts,
350
+ unresolvedGaps,
351
+ },
352
+ };
353
+ }
354
+
355
+ // Clone packet environment and predecessors to prune deterministically
356
+ const environment: ContextPacketEnvironment = {
357
+ sharedDefaults: [...packet.environment.sharedDefaults],
358
+ projectKnowledge: [...packet.environment.projectKnowledge],
359
+ currentState: [...packet.environment.currentState],
360
+ contradictions: [...packet.environment.contradictions],
361
+ activeSkills: [...packet.environment.activeSkills],
362
+ };
363
+ const predecessors: ContextPacketPredecessor[] = packet.predecessors.map((p) => ({
364
+ ...p,
365
+ artifacts: p.artifacts.map((a) => ({ ...a })),
366
+ }));
367
+
368
+ const workingPacket: FormalContextPacketV2 = {
369
+ ...packet,
370
+ environment,
371
+ predecessors,
372
+ budget: { ...packet.budget, allocatedTokens: budget },
373
+ };
374
+
375
+ // Stage 1 (L1 Defaults): Drop shared defaults first
376
+ if (environment.sharedDefaults.length > 0 && currentTokens > budget) {
377
+ prunedStages.push("L1_defaults");
378
+ while (environment.sharedDefaults.length > 0 && currentTokens > budget) {
379
+ environment.sharedDefaults.pop();
380
+ prunedItemCounts.sharedDefaults += 1;
381
+ currentTokens = estimateContextPacketTokens(workingPacket);
382
+ }
383
+ }
384
+
385
+ // Stage 2 (L2 Historical Episodes): Drop episode state items
386
+ if (currentTokens > budget) {
387
+ const episodeIndices: number[] = [];
388
+ for (let i = environment.currentState.length - 1; i >= 0; i--) {
389
+ if (environment.currentState[i]?.kind === "episode") {
390
+ episodeIndices.push(i);
391
+ }
392
+ }
393
+ if (episodeIndices.length > 0) {
394
+ prunedStages.push("L2_episodes");
395
+ for (const idx of episodeIndices) {
396
+ if (currentTokens <= budget) break;
397
+ environment.currentState.splice(idx, 1);
398
+ prunedItemCounts.episodes += 1;
399
+ currentTokens = estimateContextPacketTokens(workingPacket);
400
+ }
401
+ }
402
+ }
403
+
404
+ // Stage 3 (L2 Project Docs / Knowledge): Drop project knowledge/doc items
405
+ if (currentTokens > budget && environment.projectKnowledge.length > 0) {
406
+ prunedStages.push("L2_docs");
407
+ while (environment.projectKnowledge.length > 0 && currentTokens > budget) {
408
+ environment.projectKnowledge.pop();
409
+ prunedItemCounts.docs += 1;
410
+ currentTokens = estimateContextPacketTokens(workingPacket);
411
+ }
412
+ }
413
+
414
+ // Stage 4 (L5 Artifacts): Prune artifact content snippets, then artifact entries if still overflowing
415
+ if (currentTokens > budget) {
416
+ const hasArtifacts = predecessors.some((p) => p.artifacts.length > 0);
417
+ if (hasArtifacts) {
418
+ prunedStages.push("L5_artifacts");
419
+ // First strip snippets
420
+ for (const p of predecessors) {
421
+ for (const a of p.artifacts) {
422
+ if (a.contentSnippet) {
423
+ a.contentSnippet = undefined;
424
+ prunedItemCounts.artifactSnippets += 1;
425
+ }
426
+ }
427
+ }
428
+ currentTokens = estimateContextPacketTokens(workingPacket);
429
+
430
+ // If still overflowing, prune artifacts list
431
+ if (currentTokens > budget) {
432
+ for (const p of predecessors) {
433
+ p.artifacts = [];
434
+ }
435
+ currentTokens = estimateContextPacketTokens(workingPacket);
436
+ }
437
+ }
438
+ }
439
+
440
+ // L3 Task Objective Inviolable:
441
+ // task, plan, and acceptance criteria are inviolable and never stripped.
442
+ if (currentTokens > budget) {
443
+ unresolvedGaps.push("context_budget_exceeded_task_inviolable");
444
+ }
445
+
446
+ workingPacket.budget = {
447
+ ...packet.budget,
448
+ allocatedTokens: budget,
449
+ estimatedTokens: currentTokens,
450
+ unresolvedGaps,
451
+ };
452
+
453
+ return {
454
+ packet: workingPacket,
455
+ audit: {
456
+ initialTokens,
457
+ finalTokens: currentTokens,
458
+ budgetTokens: budget,
459
+ prunedStages,
460
+ prunedItemCounts,
461
+ unresolvedGaps,
462
+ },
463
+ };
464
+ }
465
+
294
466
  /**
295
467
  * Builds a structured handoff manifest between workflow roles.
296
468
  */
@@ -11,7 +11,7 @@ import {
11
11
  writeFileSync,
12
12
  } from "node:fs";
13
13
  import { basename, dirname, join, resolve } from "node:path";
14
- import { DatabaseSync } from "node:sqlite";
14
+ import { DatabaseSync } from "./sqlite.ts";
15
15
  import { VnextConfigError, type VnextConfigIssue } from "./vnext-config.ts";
16
16
 
17
17
  export interface DatabaseMigrationStep {