@kaelio/ktx 0.1.0-rc.5 → 0.1.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 (180) hide show
  1. package/assets/python/kaelio_ktx-0.1.0-py3-none-any.whl +0 -0
  2. package/assets/python/manifest.json +2 -2
  3. package/dist/clack.d.ts +6 -0
  4. package/dist/clack.js +23 -0
  5. package/dist/cli-program.js +5 -2
  6. package/dist/cli-program.test.js +7 -1
  7. package/dist/cli-runtime.d.ts +4 -0
  8. package/dist/cli-runtime.js +8 -1
  9. package/dist/command-schemas.d.ts +1 -1
  10. package/dist/commands/ingest-commands.js +1 -0
  11. package/dist/commands/knowledge-commands.js +5 -0
  12. package/dist/commands/mcp-commands.js +11 -3
  13. package/dist/commands/mcp-commands.test.js +30 -1
  14. package/dist/commands/sql-commands.d.ts +3 -0
  15. package/dist/commands/sql-commands.js +43 -0
  16. package/dist/commands/sql-commands.test.d.ts +1 -0
  17. package/dist/commands/sql-commands.test.js +68 -0
  18. package/dist/context-build-view.js +5 -1
  19. package/dist/dev.test.js +27 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/index.test.js +56 -21
  23. package/dist/ingest.js +123 -18
  24. package/dist/ingest.test.js +206 -0
  25. package/dist/io/print-list.d.ts +2 -1
  26. package/dist/io/print-list.js +7 -0
  27. package/dist/io/print-list.test.js +13 -11
  28. package/dist/io/symbols.d.ts +2 -2
  29. package/dist/knowledge.d.ts +1 -0
  30. package/dist/knowledge.js +34 -16
  31. package/dist/knowledge.test.js +27 -0
  32. package/dist/managed-python-command.d.ts +2 -0
  33. package/dist/managed-python-command.js +17 -9
  34. package/dist/managed-python-command.test.js +59 -4
  35. package/dist/next-steps.js +1 -1
  36. package/dist/next-steps.test.js +2 -0
  37. package/dist/print-command-tree.js +7 -1
  38. package/dist/public-ingest.d.ts +9 -1
  39. package/dist/public-ingest.js +50 -7
  40. package/dist/public-ingest.test.js +69 -2
  41. package/dist/release-version.d.ts +5 -0
  42. package/dist/release-version.js +44 -0
  43. package/dist/runtime-requirements.d.ts +23 -0
  44. package/dist/runtime-requirements.js +99 -0
  45. package/dist/runtime-requirements.test.d.ts +1 -0
  46. package/dist/runtime-requirements.test.js +63 -0
  47. package/dist/setup-agents.d.ts +11 -3
  48. package/dist/setup-agents.js +397 -134
  49. package/dist/setup-agents.test.js +359 -61
  50. package/dist/setup-embeddings.js +3 -6
  51. package/dist/setup-embeddings.test.js +18 -2
  52. package/dist/setup-models.js +2 -2
  53. package/dist/setup-models.test.js +5 -3
  54. package/dist/setup-ready-menu.d.ts +1 -1
  55. package/dist/setup-ready-menu.js +2 -0
  56. package/dist/setup-ready-menu.test.js +3 -0
  57. package/dist/setup-runtime.d.ts +45 -0
  58. package/dist/setup-runtime.js +47 -0
  59. package/dist/setup-runtime.test.d.ts +1 -0
  60. package/dist/setup-runtime.test.js +110 -0
  61. package/dist/setup-sources-notion.test.d.ts +1 -0
  62. package/dist/setup-sources-notion.test.js +107 -0
  63. package/dist/setup-sources.js +5 -2
  64. package/dist/setup.d.ts +19 -1
  65. package/dist/setup.js +104 -29
  66. package/dist/setup.test.js +221 -57
  67. package/dist/sl.js +2 -2
  68. package/dist/sl.test.js +10 -0
  69. package/dist/source-mapping.js +9 -1
  70. package/dist/source-mapping.test.d.ts +1 -0
  71. package/dist/source-mapping.test.js +65 -0
  72. package/dist/sql.d.ts +22 -0
  73. package/dist/sql.js +125 -0
  74. package/dist/sql.test.d.ts +1 -0
  75. package/dist/sql.test.js +226 -0
  76. package/node_modules/@ktx/connector-clickhouse/dist/package-exports.test.js +1 -1
  77. package/node_modules/@ktx/context/dist/connections/connection-type.d.ts +4 -4
  78. package/node_modules/@ktx/context/dist/core/git.service.d.ts +3 -0
  79. package/node_modules/@ktx/context/dist/core/git.service.js +47 -1
  80. package/node_modules/@ktx/context/dist/core/git.service.patch.test.d.ts +1 -0
  81. package/node_modules/@ktx/context/dist/core/git.service.patch.test.js +40 -0
  82. package/node_modules/@ktx/context/dist/ingest/adapters/historic-sql/types.d.ts +5 -5
  83. package/node_modules/@ktx/context/dist/ingest/adapters/looker/looker.adapter.d.ts +2 -2
  84. package/node_modules/@ktx/context/dist/ingest/adapters/looker/tools/looker-query-to-sl.tool.d.ts +2 -2
  85. package/node_modules/@ktx/context/dist/ingest/adapters/looker/types.d.ts +16 -16
  86. package/node_modules/@ktx/context/dist/ingest/adapters/lookml/pull-config.d.ts +1 -1
  87. package/node_modules/@ktx/context/dist/ingest/adapters/metabase/fetch.js +16 -0
  88. package/node_modules/@ktx/context/dist/ingest/adapters/metabase/fetch.test.js +41 -0
  89. package/node_modules/@ktx/context/dist/ingest/adapters/metricflow/metricflow.adapter.d.ts +2 -1
  90. package/node_modules/@ktx/context/dist/ingest/adapters/metricflow/metricflow.adapter.js +40 -0
  91. package/node_modules/@ktx/context/dist/ingest/adapters/metricflow/metricflow.adapter.test.js +116 -1
  92. package/node_modules/@ktx/context/dist/ingest/adapters/metricflow/projection-config.d.ts +29 -0
  93. package/node_modules/@ktx/context/dist/ingest/adapters/metricflow/projection-config.js +40 -0
  94. package/node_modules/@ktx/context/dist/ingest/adapters/metricflow/pull-config.d.ts +1 -1
  95. package/node_modules/@ktx/context/dist/ingest/artifact-gates.d.ts +25 -0
  96. package/node_modules/@ktx/context/dist/ingest/artifact-gates.js +149 -0
  97. package/node_modules/@ktx/context/dist/ingest/artifact-gates.test.d.ts +1 -0
  98. package/node_modules/@ktx/context/dist/ingest/artifact-gates.test.js +167 -0
  99. package/node_modules/@ktx/context/dist/ingest/final-gate-repair.d.ts +29 -0
  100. package/node_modules/@ktx/context/dist/ingest/final-gate-repair.js +178 -0
  101. package/node_modules/@ktx/context/dist/ingest/final-gate-repair.test.d.ts +1 -0
  102. package/node_modules/@ktx/context/dist/ingest/final-gate-repair.test.js +109 -0
  103. package/node_modules/@ktx/context/dist/ingest/index.d.ts +8 -1
  104. package/node_modules/@ktx/context/dist/ingest/index.js +7 -0
  105. package/node_modules/@ktx/context/dist/ingest/ingest-bundle.runner.d.ts +18 -2
  106. package/node_modules/@ktx/context/dist/ingest/ingest-bundle.runner.isolated-diff.test.d.ts +1 -0
  107. package/node_modules/@ktx/context/dist/ingest/ingest-bundle.runner.isolated-diff.test.js +1761 -0
  108. package/node_modules/@ktx/context/dist/ingest/ingest-bundle.runner.js +1695 -901
  109. package/node_modules/@ktx/context/dist/ingest/ingest-bundle.runner.test.js +135 -118
  110. package/node_modules/@ktx/context/dist/ingest/ingest-trace.d.ts +50 -0
  111. package/node_modules/@ktx/context/dist/ingest/ingest-trace.js +88 -0
  112. package/node_modules/@ktx/context/dist/ingest/ingest-trace.test.d.ts +1 -0
  113. package/node_modules/@ktx/context/dist/ingest/ingest-trace.test.js +76 -0
  114. package/node_modules/@ktx/context/dist/ingest/isolated-diff/git-patch.d.ts +16 -0
  115. package/node_modules/@ktx/context/dist/ingest/isolated-diff/git-patch.js +78 -0
  116. package/node_modules/@ktx/context/dist/ingest/isolated-diff/git-patch.test.d.ts +1 -0
  117. package/node_modules/@ktx/context/dist/ingest/isolated-diff/git-patch.test.js +76 -0
  118. package/node_modules/@ktx/context/dist/ingest/isolated-diff/patch-integrator.d.ts +58 -0
  119. package/node_modules/@ktx/context/dist/ingest/isolated-diff/patch-integrator.js +223 -0
  120. package/node_modules/@ktx/context/dist/ingest/isolated-diff/patch-integrator.test.d.ts +1 -0
  121. package/node_modules/@ktx/context/dist/ingest/isolated-diff/patch-integrator.test.js +369 -0
  122. package/node_modules/@ktx/context/dist/ingest/isolated-diff/textual-conflict-resolver.d.ts +23 -0
  123. package/node_modules/@ktx/context/dist/ingest/isolated-diff/textual-conflict-resolver.js +190 -0
  124. package/node_modules/@ktx/context/dist/ingest/isolated-diff/textual-conflict-resolver.test.d.ts +1 -0
  125. package/node_modules/@ktx/context/dist/ingest/isolated-diff/textual-conflict-resolver.test.js +101 -0
  126. package/node_modules/@ktx/context/dist/ingest/isolated-diff/work-unit-executor.d.ts +15 -0
  127. package/node_modules/@ktx/context/dist/ingest/isolated-diff/work-unit-executor.js +61 -0
  128. package/node_modules/@ktx/context/dist/ingest/isolated-diff/work-unit-executor.test.d.ts +1 -0
  129. package/node_modules/@ktx/context/dist/ingest/isolated-diff/work-unit-executor.test.js +137 -0
  130. package/node_modules/@ktx/context/dist/ingest/local-bundle-ingest.test.js +7 -0
  131. package/node_modules/@ktx/context/dist/ingest/local-bundle-runtime.js +54 -10
  132. package/node_modules/@ktx/context/dist/ingest/local-bundle-runtime.test.js +65 -0
  133. package/node_modules/@ktx/context/dist/ingest/memory-flow/schema.d.ts +23 -5
  134. package/node_modules/@ktx/context/dist/ingest/memory-flow/schema.js +17 -0
  135. package/node_modules/@ktx/context/dist/ingest/memory-flow/schema.test.js +1 -0
  136. package/node_modules/@ktx/context/dist/ingest/memory-flow/types.d.ts +6 -0
  137. package/node_modules/@ktx/context/dist/ingest/parsed-target-table.d.ts +1 -1
  138. package/node_modules/@ktx/context/dist/ingest/ports.d.ts +3 -0
  139. package/node_modules/@ktx/context/dist/ingest/report-snapshot.d.ts +32 -7
  140. package/node_modules/@ktx/context/dist/ingest/report-snapshot.js +25 -0
  141. package/node_modules/@ktx/context/dist/ingest/report-snapshot.test.js +124 -0
  142. package/node_modules/@ktx/context/dist/ingest/reports.d.ts +23 -0
  143. package/node_modules/@ktx/context/dist/ingest/semantic-layer-target-policy.d.ts +11 -0
  144. package/node_modules/@ktx/context/dist/ingest/semantic-layer-target-policy.js +26 -0
  145. package/node_modules/@ktx/context/dist/ingest/semantic-layer-target-policy.test.d.ts +1 -0
  146. package/node_modules/@ktx/context/dist/ingest/semantic-layer-target-policy.test.js +25 -0
  147. package/node_modules/@ktx/context/dist/ingest/stages/stage-3-work-units.d.ts +4 -0
  148. package/node_modules/@ktx/context/dist/ingest/stages/stage-3-work-units.js +4 -0
  149. package/node_modules/@ktx/context/dist/ingest/stages/stage-3-work-units.test.js +29 -0
  150. package/node_modules/@ktx/context/dist/ingest/tools/emit-unmapped-fallback.tool.d.ts +1 -1
  151. package/node_modules/@ktx/context/dist/ingest/types.d.ts +24 -0
  152. package/node_modules/@ktx/context/dist/ingest/wiki-body-refs.d.ts +24 -0
  153. package/node_modules/@ktx/context/dist/ingest/wiki-body-refs.js +111 -0
  154. package/node_modules/@ktx/context/dist/ingest/wiki-body-refs.test.d.ts +1 -0
  155. package/node_modules/@ktx/context/dist/ingest/wiki-body-refs.test.js +138 -0
  156. package/node_modules/@ktx/context/dist/llm/claude-code-runtime.js +19 -2
  157. package/node_modules/@ktx/context/dist/llm/claude-code-runtime.test.js +33 -0
  158. package/node_modules/@ktx/context/dist/project/setup-config.d.ts +1 -1
  159. package/node_modules/@ktx/context/dist/project/setup-config.js +10 -1
  160. package/node_modules/@ktx/context/dist/project/setup-config.test.js +3 -2
  161. package/node_modules/@ktx/context/dist/sl/tools/sl-edit-source.tool.js +5 -1
  162. package/node_modules/@ktx/context/dist/sl/tools/sl-edit-source.tool.test.js +15 -0
  163. package/node_modules/@ktx/context/dist/sl/tools/sl-write-source.tool.js +5 -1
  164. package/node_modules/@ktx/context/dist/sl/tools/sl-write-source.tool.test.js +22 -0
  165. package/node_modules/@ktx/context/dist/tools/action-target-connection.d.ts +9 -0
  166. package/node_modules/@ktx/context/dist/tools/action-target-connection.js +14 -0
  167. package/node_modules/@ktx/context/dist/tools/context-candidate-write.tool.d.ts +4 -4
  168. package/node_modules/@ktx/context/dist/tools/index.d.ts +1 -0
  169. package/node_modules/@ktx/context/dist/tools/index.js +1 -0
  170. package/node_modules/@ktx/context/dist/wiki/local-knowledge.js +4 -1
  171. package/node_modules/@ktx/context/dist/wiki/local-knowledge.test.js +44 -0
  172. package/node_modules/@ktx/context/dist/wiki/tools/wiki-write.tool.js +3 -48
  173. package/node_modules/@ktx/context/dist/wiki/tools/wiki-write.tool.test.js +28 -0
  174. package/node_modules/@ktx/context/dist/wiki/wiki-ref-validation.d.ts +17 -0
  175. package/node_modules/@ktx/context/dist/wiki/wiki-ref-validation.js +79 -0
  176. package/node_modules/@ktx/context/dist/wiki/wiki-ref-validation.test.d.ts +1 -0
  177. package/node_modules/@ktx/context/dist/wiki/wiki-ref-validation.test.js +64 -0
  178. package/node_modules/@ktx/context/prompts/memory_agent_bundle_ingest_work_unit.md +23 -4
  179. package/node_modules/@ktx/context/skills/ingest_triage/SKILL.md +7 -3
  180. package/package.json +4 -4
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { findInvalidWikiBodyRefs, parseWikiBodyRefs } from './wiki-body-refs.js';
3
+ const sources = [
4
+ {
5
+ name: 'mart_account_segments',
6
+ grain: ['account_id'],
7
+ columns: [
8
+ { name: 'account_id', type: 'string' },
9
+ { name: 'segment', type: 'string' },
10
+ ],
11
+ joins: [],
12
+ measures: [{ name: 'total_contract_arr', expr: 'sum(contract_arr)' }],
13
+ segments: [{ name: 'enterprise', expr: "segment = 'enterprise'" }],
14
+ table: 'analytics.mart_account_segments',
15
+ },
16
+ ];
17
+ describe('wiki body refs', () => {
18
+ it('parses only explicit inline-code body references outside fenced blocks', () => {
19
+ const body = [
20
+ 'Valid `mart_account_segments.total_contract_arr` and `source:mart_account_segments`.',
21
+ 'Also `warehouse/mart_account_segments.segment` and `table:analytics.mart_account_segments`.',
22
+ 'Ignore prose mart_account_segments.total_contract_arr_cents.',
23
+ 'Ignore `single_token`.',
24
+ 'Ignore wildcard pattern `mart_nrr_quarterly.*_arr_cents`.',
25
+ 'Ignore condition `users.is_internal = false`.',
26
+ '```sql',
27
+ 'select `mart_account_segments.total_contract_arr_cents`',
28
+ '```',
29
+ ].join('\n');
30
+ expect(parseWikiBodyRefs(body)).toEqual([
31
+ { kind: 'sl_entity', connectionId: null, sourceName: 'mart_account_segments', entityName: 'total_contract_arr' },
32
+ { kind: 'sl_source', connectionId: null, sourceName: 'mart_account_segments' },
33
+ { kind: 'sl_entity', connectionId: 'warehouse', sourceName: 'mart_account_segments', entityName: 'segment' },
34
+ { kind: 'table', connectionId: null, tableRef: 'analytics.mart_account_segments' },
35
+ ]);
36
+ });
37
+ it('rejects stale inline-code semantic-layer references', async () => {
38
+ const invalid = await findInvalidWikiBodyRefs({
39
+ pageKey: 'account-segments',
40
+ body: 'ARR is documented as `mart_account_segments.total_contract_arr_cents`.',
41
+ visibleConnectionIds: ['warehouse'],
42
+ loadSources: async () => sources,
43
+ tableExists: async () => true,
44
+ });
45
+ expect(invalid).toEqual([
46
+ 'account-segments: unknown semantic-layer entity mart_account_segments.total_contract_arr_cents',
47
+ ]);
48
+ });
49
+ it('does not treat wildcard inline-code patterns as exact semantic-layer entity references', async () => {
50
+ const invalid = await findInvalidWikiBodyRefs({
51
+ pageKey: 'revenue-metrics-encoding',
52
+ body: 'Cents columns include `mart_nrr_quarterly.*_arr_cents` and `mart_retention_movement_breakout.*_arr_cents`.',
53
+ visibleConnectionIds: ['warehouse'],
54
+ loadSources: async () => [
55
+ { name: 'mart_nrr_quarterly', grain: [], columns: [], joins: [], measures: [], table: 'analytics.mart_nrr_quarterly' },
56
+ {
57
+ name: 'mart_retention_movement_breakout',
58
+ grain: [],
59
+ columns: [],
60
+ joins: [],
61
+ measures: [],
62
+ table: 'analytics.mart_retention_movement_breakout',
63
+ },
64
+ ],
65
+ tableExists: async () => true,
66
+ });
67
+ expect(invalid).toEqual([]);
68
+ });
69
+ it('does not treat inline-code SQL predicates as exact semantic-layer entity references', async () => {
70
+ const invalid = await findInvalidWikiBodyRefs({
71
+ pageKey: 'account-reporting-exclusions',
72
+ body: 'Exclude internal users with `users.is_internal = false` and test users with `users.is_test = false`.',
73
+ visibleConnectionIds: ['warehouse'],
74
+ loadSources: async () => [
75
+ {
76
+ name: 'users',
77
+ grain: [],
78
+ columns: [
79
+ { name: 'is_internal', type: 'boolean' },
80
+ { name: 'is_test', type: 'boolean' },
81
+ ],
82
+ joins: [],
83
+ measures: [],
84
+ table: 'analytics.users',
85
+ },
86
+ ],
87
+ tableExists: async () => true,
88
+ });
89
+ expect(invalid).toEqual([]);
90
+ });
91
+ it('validates source, dimension, segment, measure, and table references', async () => {
92
+ const invalid = await findInvalidWikiBodyRefs({
93
+ pageKey: 'account-segments',
94
+ body: [
95
+ '`mart_account_segments.total_contract_arr`',
96
+ '`mart_account_segments.segment`',
97
+ '`mart_account_segments.enterprise`',
98
+ '`source:mart_account_segments`',
99
+ '`table:analytics.mart_account_segments`',
100
+ ].join('\n'),
101
+ visibleConnectionIds: ['warehouse'],
102
+ loadSources: async () => sources,
103
+ tableExists: async (_connectionId, tableRef) => tableRef === 'analytics.mart_account_segments',
104
+ });
105
+ expect(invalid).toEqual([]);
106
+ });
107
+ it('ignores two-part inline code when the source is not visible', async () => {
108
+ const invalid = await findInvalidWikiBodyRefs({
109
+ pageKey: 'engineering-notes',
110
+ body: [
111
+ 'A version token like `node.v22` is not a semantic-layer reference.',
112
+ 'A raw table must use `table:analytics.mart_account_segments`.',
113
+ ].join('\n'),
114
+ visibleConnectionIds: ['warehouse'],
115
+ loadSources: async () => sources,
116
+ tableExists: async (_connectionId, tableRef) => tableRef === 'analytics.mart_account_segments',
117
+ });
118
+ expect(invalid).toEqual([]);
119
+ });
120
+ it('still rejects explicit missing source and table references', async () => {
121
+ const invalid = await findInvalidWikiBodyRefs({
122
+ pageKey: 'account-segments',
123
+ body: [
124
+ '`source:missing_source`',
125
+ '`warehouse/source:missing_source`',
126
+ '`table:analytics.missing_table`',
127
+ ].join('\n'),
128
+ visibleConnectionIds: ['warehouse'],
129
+ loadSources: async () => sources,
130
+ tableExists: async () => false,
131
+ });
132
+ expect(invalid).toEqual([
133
+ 'account-segments: unknown semantic-layer source missing_source',
134
+ 'account-segments: unknown semantic-layer source warehouse/missing_source',
135
+ 'account-segments: unknown raw table analytics.missing_table',
136
+ ]);
137
+ });
138
+ });
@@ -18,6 +18,7 @@ const BUILTIN_TOOLS = [
18
18
  'WebSearch',
19
19
  'TodoWrite',
20
20
  ];
21
+ const KTX_MCP_SERVER_NAME = 'ktx';
21
22
  function isResult(message) {
22
23
  return message.type === 'result';
23
24
  }
@@ -66,11 +67,18 @@ function assertInitIsolation(message, allowedToolIds, expectedMcpServerNames) {
66
67
  }
67
68
  }
68
69
  function expectedMcpServerNames(tools) {
69
- return tools && Object.keys(tools).length > 0 ? new Set(['ktx']) : new Set();
70
+ return tools && Object.keys(tools).length > 0 ? new Set([KTX_MCP_SERVER_NAME]) : new Set();
71
+ }
72
+ function managedMcpSettings(serverNames) {
73
+ return {
74
+ allowManagedMcpServersOnly: true,
75
+ allowedMcpServers: serverNames.map((serverName) => ({ serverName })),
76
+ };
70
77
  }
71
78
  function baseOptions(input) {
72
79
  const toolIds = mcpToolIds(input.tools ?? {});
73
80
  const allowedToolIds = new Set(toolIds);
81
+ const expectedServerNames = [...expectedMcpServerNames(input.tools)];
74
82
  return {
75
83
  cwd: input.projectDir,
76
84
  model: input.model,
@@ -79,6 +87,8 @@ function baseOptions(input) {
79
87
  skills: [],
80
88
  plugins: [],
81
89
  tools: [],
90
+ managedSettings: managedMcpSettings(expectedServerNames),
91
+ strictMcpConfig: true,
82
92
  allowedTools: toolIds,
83
93
  disallowedTools: BUILTIN_TOOLS,
84
94
  canUseTool: async (toolName, _toolInput, options) => allowedToolIds.has(toolName)
@@ -92,7 +102,14 @@ function baseOptions(input) {
92
102
  persistSession: false,
93
103
  env: createKtxClaudeCodeEnv(input.env),
94
104
  ...(input.tools && Object.keys(input.tools).length > 0
95
- ? { mcpServers: { ktx: createSdkMcpServer({ name: 'ktx', tools: createClaudeSdkTools(input.tools) }) } }
105
+ ? {
106
+ mcpServers: {
107
+ [KTX_MCP_SERVER_NAME]: createSdkMcpServer({
108
+ name: KTX_MCP_SERVER_NAME,
109
+ tools: createClaudeSdkTools(input.tools),
110
+ }),
111
+ },
112
+ }
96
113
  : {}),
97
114
  };
98
115
  }
@@ -66,6 +66,11 @@ describe('ClaudeCodeKtxLlmRuntime', () => {
66
66
  skills: [],
67
67
  plugins: [],
68
68
  tools: [],
69
+ managedSettings: {
70
+ allowManagedMcpServersOnly: true,
71
+ allowedMcpServers: [],
72
+ },
73
+ strictMcpConfig: true,
69
74
  allowedTools: [],
70
75
  permissionMode: 'dontAsk',
71
76
  persistSession: false,
@@ -125,6 +130,11 @@ describe('ClaudeCodeKtxLlmRuntime', () => {
125
130
  });
126
131
  const options = query.mock.calls[0][0].options;
127
132
  expect(options.allowedTools).toEqual(['mcp__ktx__load_skill']);
133
+ expect(options.managedSettings).toEqual({
134
+ allowManagedMcpServersOnly: true,
135
+ allowedMcpServers: [{ serverName: 'ktx' }],
136
+ });
137
+ expect(options.strictMcpConfig).toBe(true);
128
138
  expect(await options.canUseTool('mcp__ktx__load_skill', {}, { signal: new AbortController().signal, toolUseID: '1' })).toEqual({
129
139
  behavior: 'allow',
130
140
  toolUseID: '1',
@@ -155,6 +165,11 @@ describe('ClaudeCodeKtxLlmRuntime', () => {
155
165
  skills: [],
156
166
  plugins: [],
157
167
  tools: [],
168
+ managedSettings: {
169
+ allowManagedMcpServersOnly: true,
170
+ allowedMcpServers: [],
171
+ },
172
+ strictMcpConfig: true,
158
173
  allowedTools: [],
159
174
  permissionMode: 'dontAsk',
160
175
  persistSession: false,
@@ -233,6 +248,11 @@ describe('ClaudeCodeKtxLlmRuntime', () => {
233
248
  })).resolves.toEqual({ stopReason: 'budget' });
234
249
  const options = query.mock.calls[0][0].options;
235
250
  expect(options.allowedTools).toEqual(['mcp__ktx__load_skill']);
251
+ expect(options.managedSettings).toEqual({
252
+ allowManagedMcpServersOnly: true,
253
+ allowedMcpServers: [{ serverName: 'ktx' }],
254
+ });
255
+ expect(options.strictMcpConfig).toBe(true);
236
256
  expect(await options.canUseTool('mcp__ktx__load_skill', {}, { signal: new AbortController().signal, toolUseID: '1' })).toEqual({
237
257
  behavior: 'allow',
238
258
  toolUseID: '1',
@@ -287,6 +307,10 @@ describe('ClaudeCodeKtxLlmRuntime', () => {
287
307
  answer: 'yes',
288
308
  });
289
309
  expect(objectQuery.mock.calls[0][0].options.env).toEqual(expect.objectContaining({ PATH: '/usr/bin' }));
310
+ expect(objectQuery.mock.calls[0][0].options.managedSettings).toEqual({
311
+ allowManagedMcpServersOnly: true,
312
+ allowedMcpServers: [],
313
+ });
290
314
  expect(objectQuery.mock.calls[0][0].options.env).not.toEqual(expect.objectContaining({ ANTHROPIC_API_KEY: 'sk-ant-test', AWS_PROFILE: 'prod' }));
291
315
  const agentQuery = vi.fn((_input) => stream([
292
316
  initMessage({ tools: ['mcp__ktx__load_skill'], mcp_servers: [{ name: 'ktx', status: 'connected' }] }),
@@ -321,6 +345,10 @@ describe('ClaudeCodeKtxLlmRuntime', () => {
321
345
  telemetryTags: { operationName: 'test' },
322
346
  });
323
347
  expect(agentQuery.mock.calls[0][0].options.env).toEqual(expect.objectContaining({ HOME: '/Users/test' }));
348
+ expect(agentQuery.mock.calls[0][0].options.managedSettings).toEqual({
349
+ allowManagedMcpServersOnly: true,
350
+ allowedMcpServers: [{ serverName: 'ktx' }],
351
+ });
324
352
  expect(agentQuery.mock.calls[0][0].options.env).not.toEqual(expect.objectContaining({ ANTHROPIC_AUTH_TOKEN: 'token', CLAUDE_CODE_USE_VERTEX: '1' }));
325
353
  });
326
354
  it('logs and ignores onStepFinish callback errors', async () => {
@@ -376,6 +404,11 @@ describe('ClaudeCodeKtxLlmRuntime', () => {
376
404
  skills: [],
377
405
  plugins: [],
378
406
  tools: [],
407
+ managedSettings: {
408
+ allowManagedMcpServersOnly: true,
409
+ allowedMcpServers: [],
410
+ },
411
+ strictMcpConfig: true,
379
412
  allowedTools: [],
380
413
  persistSession: false,
381
414
  env: expect.not.objectContaining({ ANTHROPIC_API_KEY: 'sk-ant-test' }),
@@ -1,5 +1,5 @@
1
1
  import type { KtxProjectConfig } from './config.js';
2
- export declare const KTX_SETUP_STEPS: readonly ["project", "llm", "embeddings", "databases", "sources", "context", "agents"];
2
+ export declare const KTX_SETUP_STEPS: readonly ["project", "llm", "embeddings", "databases", "sources", "runtime", "context", "agents"];
3
3
  export type KtxSetupStep = (typeof KTX_SETUP_STEPS)[number];
4
4
  export interface KtxSetupState {
5
5
  completed_steps: KtxSetupStep[];
@@ -1,6 +1,15 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- export const KTX_SETUP_STEPS = ['project', 'llm', 'embeddings', 'databases', 'sources', 'context', 'agents'];
3
+ export const KTX_SETUP_STEPS = [
4
+ 'project',
5
+ 'llm',
6
+ 'embeddings',
7
+ 'databases',
8
+ 'sources',
9
+ 'runtime',
10
+ 'context',
11
+ 'agents',
12
+ ];
4
13
  const SETUP_GITIGNORE_ENTRIES = [
5
14
  'cache/',
6
15
  'db.sqlite',
@@ -16,11 +16,12 @@ describe('KTX setup config helpers', () => {
16
16
  await markKtxSetupStateStepComplete(tempDir, 'project');
17
17
  await markKtxSetupStateStepComplete(tempDir, 'project');
18
18
  await markKtxSetupStateStepComplete(tempDir, 'llm');
19
+ await markKtxSetupStateStepComplete(tempDir, 'runtime');
19
20
  await markKtxSetupStateStepComplete(tempDir, 'context');
20
21
  expect(await readKtxSetupState(tempDir)).toEqual({
21
- completed_steps: ['project', 'llm', 'context'],
22
+ completed_steps: ['project', 'llm', 'runtime', 'context'],
22
23
  });
23
- await expect(readFile(join(tempDir, '.ktx', 'setup', 'state.json'), 'utf-8')).resolves.toBe(`${JSON.stringify({ completed_steps: ['project', 'llm', 'context'] }, null, 2)}\n`);
24
+ await expect(readFile(join(tempDir, '.ktx', 'setup', 'state.json'), 'utf-8')).resolves.toBe(`${JSON.stringify({ completed_steps: ['project', 'llm', 'runtime', 'context'] }, null, 2)}\n`);
24
25
  });
25
26
  it('sets setup database connection ids without duplicates', () => {
26
27
  const config = buildDefaultKtxProjectConfig();
@@ -1,6 +1,6 @@
1
1
  import YAML from 'yaml';
2
2
  import { z } from 'zod';
3
- import { addTouchedSlSource, validateActionRawPaths } from '../../tools/index.js';
3
+ import { addTouchedSlSource, validateActionRawPaths, validateActionTargetConnection, } from '../../tools/index.js';
4
4
  import { applySqlEdits } from '../../tools/sql-edit-replacer.js';
5
5
  import { normalizeSemanticLayerDescriptions } from '../description-normalization.js';
6
6
  import { BaseSemanticLayerTool, } from './base-semantic-layer.tool.js';
@@ -59,6 +59,10 @@ If no source exists yet, use sl_write_source instead — this tool will reject t
59
59
  const { name: author, email: authorEmail } = await this.authorResolver.resolve(context.userId);
60
60
  const semanticLayerService = context.session?.semanticLayerService ?? this.semanticLayerService;
61
61
  const skipIndex = context.session?.isWorktreeScoped === true;
62
+ const targetConnectionValidation = validateActionTargetConnection(context.session, connectionId);
63
+ if (!targetConnectionValidation.ok) {
64
+ return this.buildOutput(false, [targetConnectionValidation.error], sourceName);
65
+ }
62
66
  const rawPathValidation = validateActionRawPaths(context.session, input.rawPaths);
63
67
  if (!rawPathValidation.ok) {
64
68
  return this.buildOutput(false, [rawPathValidation.error], sourceName);
@@ -80,6 +80,21 @@ describe('SlEditSourceTool — session gating', () => {
80
80
  targetConnectionId: warehouseConnectionId,
81
81
  }));
82
82
  });
83
+ it('rejects session-scoped edits outside allowed target connections', async () => {
84
+ const { tool } = makeTool();
85
+ const session = makeSession({
86
+ allowedConnectionNames: new Set(['warehouse']),
87
+ });
88
+ const context = { ...baseContext, session };
89
+ const result = await tool.call({
90
+ connectionId: 'finance',
91
+ sourceName: 'orders',
92
+ yaml_edits: [{ oldText: 'measures: []', newText: 'measures: []' }],
93
+ }, context);
94
+ expect(result.structured.success).toBe(false);
95
+ expect(result.markdown).toContain('connectionId "finance" is outside this ingest session');
96
+ expect(session.actions).toEqual([]);
97
+ });
83
98
  it('indexes normally when no session is present', async () => {
84
99
  const { tool, slSearchService } = makeTool();
85
100
  const result = await tool.call({
@@ -1,6 +1,6 @@
1
1
  import YAML from 'yaml';
2
2
  import { z } from 'zod';
3
- import { addTouchedSlSource, validateActionRawPaths } from '../../tools/index.js';
3
+ import { addTouchedSlSource, validateActionRawPaths, validateActionTargetConnection, } from '../../tools/index.js';
4
4
  import { sourceOverlaySchema } from '../schemas.js';
5
5
  import { BaseSemanticLayerTool, sourceDefinitionSchema, } from './base-semantic-layer.tool.js';
6
6
  import { normalizeSemanticLayerDescriptions } from '../description-normalization.js';
@@ -83,6 +83,10 @@ Do NOT join back to a table that the SQL already aggregates from if the grain co
83
83
  const { name: author, email: authorEmail } = await this.authorResolver.resolve(context.userId);
84
84
  const semanticLayerService = context.session?.semanticLayerService ?? this.semanticLayerService;
85
85
  const skipIndex = context.session?.isWorktreeScoped === true;
86
+ const targetConnectionValidation = validateActionTargetConnection(context.session, connectionId);
87
+ if (!targetConnectionValidation.ok) {
88
+ return this.buildOutput(false, [targetConnectionValidation.error], sourceName);
89
+ }
86
90
  const rawPathValidation = validateActionRawPaths(context.session, input.rawPaths);
87
91
  if (!rawPathValidation.ok) {
88
92
  return this.buildOutput(false, [rawPathValidation.error], sourceName);
@@ -112,6 +112,28 @@ describe('SlWriteSourceTool — session gating', () => {
112
112
  targetConnectionId: warehouseConnectionId,
113
113
  }));
114
114
  });
115
+ it('rejects session-scoped writes outside allowed target connections', async () => {
116
+ const { tool } = makeTool();
117
+ const session = makeSession({
118
+ allowedConnectionNames: new Set(['warehouse']),
119
+ });
120
+ const context = { ...baseContext, session };
121
+ const result = await tool.call({
122
+ connectionId: 'finance',
123
+ sourceName: 'finance_orders',
124
+ source: {
125
+ name: 'finance_orders',
126
+ table: 'public.orders',
127
+ grain: ['id'],
128
+ columns: [{ name: 'id', type: 'string' }],
129
+ measures: [],
130
+ joins: [],
131
+ },
132
+ }, context);
133
+ expect(result.structured.success).toBe(false);
134
+ expect(result.markdown).toContain('connectionId "finance" is outside this ingest session');
135
+ expect(session.actions).toEqual([]);
136
+ });
115
137
  it('indexes normally when no session is present', async () => {
116
138
  const { tool, slSearchService } = makeTool();
117
139
  const result = await tool.call({
@@ -0,0 +1,9 @@
1
+ import type { ToolSession } from './tool-session.js';
2
+ type ActionTargetConnectionValidation = {
3
+ ok: true;
4
+ } | {
5
+ ok: false;
6
+ error: string;
7
+ };
8
+ export declare function validateActionTargetConnection(session: ToolSession | undefined, connectionId: string): ActionTargetConnectionValidation;
9
+ export {};
@@ -0,0 +1,14 @@
1
+ export function validateActionTargetConnection(session, connectionId) {
2
+ const allowed = session?.allowedConnectionNames;
3
+ if (!allowed) {
4
+ return { ok: true };
5
+ }
6
+ if (allowed.has(connectionId)) {
7
+ return { ok: true };
8
+ }
9
+ const allowedList = [...allowed].sort();
10
+ return {
11
+ ok: false,
12
+ error: `connectionId "${connectionId}" is outside this ingest session's allowed target connections: ${allowedList.length > 0 ? allowedList.join(', ') : '(none)'}`,
13
+ };
14
+ }
@@ -13,9 +13,9 @@ declare const contextCandidateWriteInputSchema: z.ZodObject<{
13
13
  actionHint: z.ZodEnum<{
14
14
  merge: "merge";
15
15
  conflict: "conflict";
16
- skip: "skip";
17
- update: "update";
18
16
  create: "create";
17
+ update: "update";
18
+ skip: "skip";
19
19
  }>;
20
20
  durabilityScore: z.ZodNumber;
21
21
  authorityScore: z.ZodNumber;
@@ -48,9 +48,9 @@ export declare class ContextCandidateWriteTool extends BaseTool<typeof contextCa
48
48
  actionHint: z.ZodEnum<{
49
49
  merge: "merge";
50
50
  conflict: "conflict";
51
- skip: "skip";
52
- update: "update";
53
51
  create: "create";
52
+ update: "update";
53
+ skip: "skip";
54
54
  }>;
55
55
  durabilityScore: z.ZodNumber;
56
56
  authorityScore: z.ZodNumber;
@@ -14,5 +14,6 @@ export type { SqlEdit } from './sql-edit-replacer.js';
14
14
  export { applySqlEdits } from './sql-edit-replacer.js';
15
15
  export type { IngestToolMetadata, MemoryAction, ToolSession } from './tool-session.js';
16
16
  export { validateActionRawPaths } from './action-raw-paths.js';
17
+ export { validateActionTargetConnection } from './action-target-connection.js';
17
18
  export type { TouchedSlSource, TouchedSlSourceSet } from './touched-sl-sources.js';
18
19
  export { addTouchedSlSource, createTouchedSlSources, deleteTouchedSlSource, hasTouchedSlSource, listTouchedSlSources, touchedSlSourceCount, touchedSlSourceNamesForConnection, } from './touched-sl-sources.js';
@@ -8,4 +8,5 @@ export { ContextEvidenceSearchTool } from './context-evidence-search.tool.js';
8
8
  export { ingestMetadataRequired, resolveIngestMetadata } from './context-ingest-metadata.js';
9
9
  export { applySqlEdits } from './sql-edit-replacer.js';
10
10
  export { validateActionRawPaths } from './action-raw-paths.js';
11
+ export { validateActionTargetConnection } from './action-target-connection.js';
11
12
  export { addTouchedSlSource, createTouchedSlSources, deleteTouchedSlSource, hasTouchedSlSource, listTouchedSlSources, touchedSlSourceCount, touchedSlSourceNamesForConnection, } from './touched-sl-sources.js';
@@ -211,6 +211,7 @@ async function searchLocalKnowledgePagesWithSqlite(project, input) {
211
211
  },
212
212
  {
213
213
  lane: 'semantic',
214
+ weight: 3,
214
215
  async generate(args) {
215
216
  if (!embeddingService) {
216
217
  return { status: 'skipped', candidates: [], reason: 'embedding_unconfigured' };
@@ -222,7 +223,9 @@ async function searchLocalKnowledgePagesWithSqlite(project, input) {
222
223
  limit: args.laneCandidatePoolLimit,
223
224
  });
224
225
  return {
225
- candidates: rows.map((row) => ({ id: row.id, rank: row.rank, rawScore: row.rawScore })),
226
+ candidates: rows
227
+ .filter((row) => row.rawScore > 0)
228
+ .map((row, index) => ({ id: row.id, rank: index + 1, rawScore: row.rawScore })),
226
229
  };
227
230
  }
228
231
  catch (error) {
@@ -13,6 +13,22 @@ class FakeEmbeddingPort {
13
13
  return Promise.all(texts.map((text) => this.computeEmbedding(text)));
14
14
  }
15
15
  }
16
+ class ArrSynonymEmbeddingPort {
17
+ maxBatchSize = 16;
18
+ async computeEmbedding(text) {
19
+ const lower = text.toLowerCase();
20
+ if (lower.trim() === 'annual recurring revenue' || lower.includes('arr') || lower.includes('contract-first')) {
21
+ return [1, 0];
22
+ }
23
+ if (lower.includes('net revenue') || lower.includes('gross') || lower.includes('refund')) {
24
+ return [0, 1];
25
+ }
26
+ return [0.5, 0.5];
27
+ }
28
+ async computeEmbeddingsBulk(texts) {
29
+ return Promise.all(texts.map((text) => this.computeEmbedding(text)));
30
+ }
31
+ }
16
32
  describe('local knowledge helpers', () => {
17
33
  let tempDir;
18
34
  let project;
@@ -108,6 +124,34 @@ describe('local knowledge helpers', () => {
108
124
  lanes: expect.arrayContaining([expect.objectContaining({ lane: 'semantic', status: 'available' })]),
109
125
  });
110
126
  });
127
+ it('ranks ARR synonym queries by semantic page embeddings over stronger lexical revenue matches', async () => {
128
+ await writeLocalKnowledgePage(project, {
129
+ key: 'arr-definition',
130
+ scope: 'GLOBAL',
131
+ summary: 'ARR is calculated contract-first for active customer contracts.',
132
+ content: 'Contract-first active contract value takes precedence over subscription values.',
133
+ tags: ['arr', 'contracts', 'finance'],
134
+ });
135
+ await writeLocalKnowledgePage(project, {
136
+ key: 'net-revenue-definition',
137
+ scope: 'GLOBAL',
138
+ summary: 'Net revenue definition',
139
+ content: 'Annual revenue is gross invoice revenue minus credits and refunds.',
140
+ tags: ['revenue', 'finance'],
141
+ });
142
+ const search = await searchLocalKnowledgePages(project, {
143
+ query: 'annual recurring revenue',
144
+ userId: 'local',
145
+ limit: 2,
146
+ embeddingService: new ArrSynonymEmbeddingPort(),
147
+ });
148
+ expect(search.map((result) => result.key)).toEqual(['arr-definition', 'net-revenue-definition']);
149
+ expect(search[0]).toMatchObject({
150
+ key: 'arr-definition',
151
+ matchReasons: expect.arrayContaining(['semantic']),
152
+ lanes: expect.arrayContaining([expect.objectContaining({ lane: 'semantic', status: 'available' })]),
153
+ });
154
+ });
111
155
  it('reports semantic lane as skipped when wiki embeddings are not configured', async () => {
112
156
  await writeLocalKnowledgePage(project, {
113
157
  key: 'metrics-revenue',
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { validateFlatWikiKey } from '../keys.js';
3
+ import { findMissingWikiRefs } from '../wiki-ref-validation.js';
3
4
  import { applySqlEdits } from '../../tools/sql-edit-replacer.js';
4
5
  import { BaseTool, validateActionRawPaths } from '../../tools/index.js';
5
6
  const MAX_USER_BLOCKS = 100;
@@ -51,53 +52,6 @@ function normalizeAccidentalEscapedMarkdownNewlines(content) {
51
52
  return content;
52
53
  return content.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\\r/g, '\n');
53
54
  }
54
- function isWikiPageKeyRef(ref) {
55
- return /^[a-z0-9][a-z0-9_-]*(?:-[a-z0-9_]+)*$/.test(ref);
56
- }
57
- function extractInlineWikiRefs(content) {
58
- const refs = new Set();
59
- const re = /\[\[([^\]\n]+)\]\]/g;
60
- for (const match of content.matchAll(re)) {
61
- const target = match[1]?.split('|', 1)[0]?.trim();
62
- if (target && isWikiPageKeyRef(target)) {
63
- refs.add(target);
64
- }
65
- }
66
- return [...refs].sort();
67
- }
68
- async function visibleWikiPageKeys(wikiService, scope, scopeId) {
69
- const keys = new Set();
70
- if (scope === 'USER') {
71
- for (const key of await wikiService.listPageKeys('GLOBAL', null)) {
72
- keys.add(key);
73
- }
74
- for (const key of await wikiService.listPageKeys('USER', scopeId)) {
75
- keys.add(key);
76
- }
77
- return keys;
78
- }
79
- for (const key of await wikiService.listPageKeys('GLOBAL', null)) {
80
- keys.add(key);
81
- }
82
- return keys;
83
- }
84
- async function findMissingWikiRefs(input) {
85
- const candidates = new Set();
86
- for (const ref of input.refs ?? []) {
87
- if (isWikiPageKeyRef(ref)) {
88
- candidates.add(ref);
89
- }
90
- }
91
- for (const ref of extractInlineWikiRefs(input.content)) {
92
- candidates.add(ref);
93
- }
94
- if (candidates.size === 0) {
95
- return [];
96
- }
97
- const available = await visibleWikiPageKeys(input.wikiService, input.scope, input.scopeId);
98
- available.add(input.pageKey);
99
- return [...candidates].filter((ref) => !available.has(ref)).sort();
100
- }
101
55
  export class WikiWriteTool extends BaseTool {
102
56
  wikiService;
103
57
  pagesRepository;
@@ -208,7 +162,8 @@ Keys must be flat file names, not directory paths. Use tags/source frontmatter f
208
162
  refs: finalFm.refs,
209
163
  content: finalContent,
210
164
  });
211
- if (missingRefs.length > 0) {
165
+ const deferMissingRefs = !!context.session?.ingest;
166
+ if (!deferMissingRefs && missingRefs.length > 0) {
212
167
  return {
213
168
  markdown: `Error: wiki references target missing page(s): ${missingRefs.join(', ')}. ` +
214
169
  'Create those pages first, retarget the links, or remove the refs.',
@@ -233,4 +233,32 @@ describe('WikiWriteTool', () => {
233
233
  expect(result.markdown).toMatch(/orbit-team-lanes-detail/);
234
234
  expect(wikiService.writePage).not.toHaveBeenCalled();
235
235
  });
236
+ it('accepts forward refs during ingest sessions for post-pass validation', async () => {
237
+ const { tool, wikiService } = makeTool({
238
+ wikiService: {
239
+ listPageKeys: vi.fn().mockResolvedValue(['orbit-company-overview']),
240
+ },
241
+ });
242
+ const session = {
243
+ connectionId: 'conn-1',
244
+ isWorktreeScoped: true,
245
+ preHead: null,
246
+ touchedSlSources: createTouchedSlSources(),
247
+ actions: [],
248
+ semanticLayerService: {},
249
+ wikiService: wikiService,
250
+ configService: {},
251
+ gitService: {},
252
+ ingest: { runId: 'run-1', jobId: 'job-1', syncId: 'sync-1', sourceKey: 'notion' },
253
+ };
254
+ const result = await tool.call({
255
+ key: 'orbit-how-we-work',
256
+ summary: 'Operating norms',
257
+ content: 'See [[orbit-team-lanes-detail]].',
258
+ refs: ['orbit-company-overview', 'orbit-team-lanes-detail'],
259
+ }, { ...baseContext, session });
260
+ expect(result.structured).toMatchObject({ success: true, key: 'orbit-how-we-work', action: 'created' });
261
+ expect(wikiService.writePage).toHaveBeenCalledTimes(1);
262
+ expect(session.actions).toContainEqual(expect.objectContaining({ target: 'wiki', type: 'created', key: 'orbit-how-we-work' }));
263
+ });
236
264
  });