@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,369 @@
1
+ import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { GitService } from '../../core/index.js';
6
+ import { FileIngestTraceWriter } from '../ingest-trace.js';
7
+ import { integrateWorkUnitPatch } from './patch-integrator.js';
8
+ async function makeRepo() {
9
+ const homeDir = await mkdtemp(join(tmpdir(), 'ktx-integrate-'));
10
+ const configDir = join(homeDir, 'config');
11
+ const git = new GitService({
12
+ storage: { configDir, homeDir },
13
+ git: {
14
+ userName: 'System User',
15
+ userEmail: 'system@example.com',
16
+ bootstrapMessage: 'init',
17
+ bootstrapAuthor: 'system',
18
+ bootstrapAuthorEmail: 'system@example.com',
19
+ },
20
+ });
21
+ await git.onModuleInit();
22
+ await mkdir(join(configDir, 'wiki/global'), { recursive: true });
23
+ await writeFile(join(configDir, 'wiki/global/a.md'), 'old\n');
24
+ await git.commitFiles(['wiki/global/a.md'], 'base', 'System User', 'system@example.com');
25
+ return { homeDir, configDir, git, baseSha: await git.revParseHead() };
26
+ }
27
+ describe('integrateWorkUnitPatch', () => {
28
+ it('applies a clean patch, runs semantic gates, and commits accepted changes', async () => {
29
+ const { homeDir, configDir, git, baseSha } = await makeRepo();
30
+ const childDir = join(homeDir, 'child');
31
+ await git.addWorktree(childDir, 'child', baseSha);
32
+ const childGit = git.forWorktree(childDir);
33
+ await writeFile(join(childDir, 'wiki/global/a.md'), 'new\n');
34
+ await childGit.commitFiles(['wiki/global/a.md'], 'edit', 'System User', 'system@example.com');
35
+ const patchPath = join(homeDir, 'patches/wu.patch');
36
+ await childGit.writeBinaryNoRenamePatch(baseSha, 'HEAD', patchPath);
37
+ const trace = new FileIngestTraceWriter({
38
+ tracePath: join(homeDir, '.ktx/ingest-traces/job-1/trace.jsonl'),
39
+ jobId: 'job-1',
40
+ connectionId: 'c1',
41
+ sourceKey: 'fake',
42
+ level: 'trace',
43
+ });
44
+ const result = await integrateWorkUnitPatch({
45
+ unitKey: 'wu-1',
46
+ patchPath,
47
+ integrationGit: git,
48
+ trace,
49
+ author: { name: 'KTX Test', email: 'system@ktx.local' },
50
+ validateAppliedTree: vi.fn().mockResolvedValue(undefined),
51
+ slDisallowed: false,
52
+ allowedTargetConnectionIds: new Set(['c1']),
53
+ });
54
+ expect(result.status).toBe('accepted');
55
+ await expect(readFile(join(configDir, 'wiki/global/a.md'), 'utf-8')).resolves.toBe('new\n');
56
+ await expect(readFile(trace.tracePath, 'utf-8')).resolves.toContain('patch_apply_finished');
57
+ });
58
+ it('rolls back and classifies semantic conflicts', async () => {
59
+ const { homeDir, configDir, git, baseSha } = await makeRepo();
60
+ const childDir = join(homeDir, 'child-semantic');
61
+ await git.addWorktree(childDir, 'child-semantic', baseSha);
62
+ const childGit = git.forWorktree(childDir);
63
+ await writeFile(join(childDir, 'wiki/global/a.md'), 'bad\n');
64
+ await childGit.commitFiles(['wiki/global/a.md'], 'bad edit', 'System User', 'system@example.com');
65
+ const patchPath = join(homeDir, 'patches/bad.patch');
66
+ await childGit.writeBinaryNoRenamePatch(baseSha, 'HEAD', patchPath);
67
+ const trace = new FileIngestTraceWriter({
68
+ tracePath: join(homeDir, '.ktx/ingest-traces/job-2/trace.jsonl'),
69
+ jobId: 'job-2',
70
+ connectionId: 'c1',
71
+ sourceKey: 'fake',
72
+ level: 'trace',
73
+ });
74
+ const result = await integrateWorkUnitPatch({
75
+ unitKey: 'wu-bad',
76
+ patchPath,
77
+ integrationGit: git,
78
+ trace,
79
+ author: { name: 'KTX Test', email: 'system@ktx.local' },
80
+ validateAppliedTree: vi.fn().mockRejectedValue(new Error('final artifact gates failed')),
81
+ slDisallowed: false,
82
+ allowedTargetConnectionIds: new Set(['c1']),
83
+ });
84
+ expect(result.status).toBe('semantic_conflict');
85
+ await expect(readFile(join(configDir, 'wiki/global/a.md'), 'utf-8')).resolves.toBe('old\n');
86
+ });
87
+ it('classifies slDisallowed patch policy failures as traced textual conflicts', async () => {
88
+ const { homeDir, configDir, git, baseSha } = await makeRepo();
89
+ await mkdir(join(configDir, 'semantic-layer/c1'), { recursive: true });
90
+ await git.commitFiles(['semantic-layer/c1'], 'empty sl dir', 'System User', 'system@example.com');
91
+ const childDir = join(homeDir, 'child-policy');
92
+ await git.addWorktree(childDir, 'child-policy', baseSha);
93
+ const childGit = git.forWorktree(childDir);
94
+ await mkdir(join(childDir, 'semantic-layer/c1'), { recursive: true });
95
+ await writeFile(join(childDir, 'semantic-layer/c1/orders.yaml'), 'name: orders\ncolumns: []\njoins: []\nmeasures: []\n');
96
+ await childGit.commitFiles(['semantic-layer/c1/orders.yaml'], 'forbidden sl', 'System User', 'system@example.com');
97
+ const patchPath = join(homeDir, 'patches/forbidden.patch');
98
+ await childGit.writeBinaryNoRenamePatch(baseSha, 'HEAD', patchPath);
99
+ const trace = new FileIngestTraceWriter({
100
+ tracePath: join(homeDir, '.ktx/ingest-traces/job-policy/trace.jsonl'),
101
+ jobId: 'job-policy',
102
+ connectionId: 'c1',
103
+ sourceKey: 'fake',
104
+ level: 'trace',
105
+ });
106
+ const result = await integrateWorkUnitPatch({
107
+ unitKey: 'lookml-mismatch',
108
+ patchPath,
109
+ integrationGit: git,
110
+ trace,
111
+ author: { name: 'KTX Test', email: 'system@ktx.local' },
112
+ validateAppliedTree: vi.fn().mockResolvedValue(undefined),
113
+ slDisallowed: true,
114
+ allowedTargetConnectionIds: new Set(['c1']),
115
+ });
116
+ expect(result).toMatchObject({
117
+ status: 'textual_conflict',
118
+ touchedPaths: ['semantic-layer/c1/orders.yaml'],
119
+ });
120
+ const rawTrace = await readFile(trace.tracePath, 'utf-8');
121
+ expect(rawTrace).toContain('patch_policy_rejected');
122
+ expect(rawTrace).toContain('slDisallowed WorkUnit lookml-mismatch touched semantic-layer/c1/orders.yaml');
123
+ });
124
+ it('classifies unauthorized semantic-layer targets as traced textual conflicts', async () => {
125
+ const { homeDir, git, baseSha } = await makeRepo();
126
+ const childDir = join(homeDir, 'child-target-policy');
127
+ await git.addWorktree(childDir, 'child-target-policy', baseSha);
128
+ const childGit = git.forWorktree(childDir);
129
+ await mkdir(join(childDir, 'semantic-layer/finance'), { recursive: true });
130
+ await writeFile(join(childDir, 'semantic-layer/finance/orders.yaml'), 'name: orders\ncolumns: []\njoins: []\nmeasures: []\n');
131
+ await childGit.commitFiles(['semantic-layer/finance/orders.yaml'], 'unauthorized sl', 'System User', 'system@example.com');
132
+ const patchPath = join(homeDir, 'patches/unauthorized.patch');
133
+ await childGit.writeBinaryNoRenamePatch(baseSha, 'HEAD', patchPath);
134
+ const trace = new FileIngestTraceWriter({
135
+ tracePath: join(homeDir, '.ktx/ingest-traces/job-target-policy/trace.jsonl'),
136
+ jobId: 'job-target-policy',
137
+ connectionId: 'c1',
138
+ sourceKey: 'fake',
139
+ level: 'trace',
140
+ });
141
+ const result = await integrateWorkUnitPatch({
142
+ unitKey: 'wu-finance',
143
+ patchPath,
144
+ integrationGit: git,
145
+ trace,
146
+ author: { name: 'KTX Test', email: 'system@ktx.local' },
147
+ validateAppliedTree: vi.fn().mockResolvedValue(undefined),
148
+ slDisallowed: false,
149
+ allowedTargetConnectionIds: new Set(['warehouse']),
150
+ });
151
+ expect(result).toMatchObject({
152
+ status: 'textual_conflict',
153
+ touchedPaths: ['semantic-layer/finance/orders.yaml'],
154
+ });
155
+ const rawTrace = await readFile(trace.tracePath, 'utf-8');
156
+ expect(rawTrace).toContain('patch_policy_rejected');
157
+ expect(rawTrace).toContain('semantic-layer target connection not allowed');
158
+ expect(rawTrace).toContain('allowedTargetConnectionIds');
159
+ });
160
+ it('repairs a textual conflict through the bounded resolver and commits repaired files', async () => {
161
+ const { homeDir, configDir, git, baseSha } = await makeRepo();
162
+ await mkdir(join(configDir, 'wiki/global'), { recursive: true });
163
+ await writeFile(join(configDir, 'wiki/global/a.md'), 'base\n', 'utf-8');
164
+ await git.commitFiles(['wiki/global/a.md'], 'base page', 'System User', 'system@example.com');
165
+ const conflictBase = await git.revParseHead();
166
+ await writeFile(join(configDir, 'wiki/global/a.md'), 'accepted\n', 'utf-8');
167
+ await git.commitFiles(['wiki/global/a.md'], 'accepted edit', 'System User', 'system@example.com');
168
+ const childDir = join(homeDir, 'child-conflict');
169
+ await git.addWorktree(childDir, 'child-conflict', conflictBase);
170
+ const childGit = git.forWorktree(childDir);
171
+ await writeFile(join(childDir, 'wiki/global/a.md'), 'proposal\n', 'utf-8');
172
+ await childGit.commitFiles(['wiki/global/a.md'], 'proposal edit', 'System User', 'system@example.com');
173
+ const patchPath = join(homeDir, 'proposal.patch');
174
+ await childGit.writeBinaryNoRenamePatch(conflictBase, 'HEAD', patchPath);
175
+ const trace = new FileIngestTraceWriter({
176
+ tracePath: join(homeDir, '.ktx/ingest-traces/job-resolver/trace.jsonl'),
177
+ jobId: 'job-resolver',
178
+ connectionId: 'warehouse',
179
+ sourceKey: 'metabase',
180
+ level: 'trace',
181
+ });
182
+ const validateAppliedTree = vi.fn(async (paths) => {
183
+ expect(paths).toEqual(['wiki/global/a.md']);
184
+ await expect(readFile(join(configDir, 'wiki/global/a.md'), 'utf-8')).resolves.toBe('accepted\nproposal\n');
185
+ });
186
+ const result = await integrateWorkUnitPatch({
187
+ unitKey: 'wu-conflict',
188
+ patchPath,
189
+ integrationGit: git,
190
+ trace,
191
+ author: { name: 'System User', email: 'system@example.com' },
192
+ slDisallowed: false,
193
+ allowedTargetConnectionIds: new Set(['warehouse']),
194
+ validateAppliedTree,
195
+ resolveTextualConflict: vi.fn(async (context) => {
196
+ expect(context).toMatchObject({
197
+ unitKey: 'wu-conflict',
198
+ patchPath,
199
+ touchedPaths: ['wiki/global/a.md'],
200
+ });
201
+ await writeFile(join(configDir, 'wiki/global/a.md'), 'accepted\nproposal\n', 'utf-8');
202
+ return {
203
+ status: 'repaired',
204
+ attempts: 1,
205
+ changedPaths: ['wiki/global/a.md'],
206
+ };
207
+ }),
208
+ });
209
+ expect(result).toMatchObject({
210
+ status: 'accepted',
211
+ touchedPaths: ['wiki/global/a.md'],
212
+ textualResolution: {
213
+ status: 'repaired',
214
+ attempts: 1,
215
+ changedPaths: ['wiki/global/a.md'],
216
+ },
217
+ });
218
+ expect(validateAppliedTree).toHaveBeenCalledOnce();
219
+ await expect(readFile(join(configDir, 'wiki/global/a.md'), 'utf-8')).resolves.toBe('accepted\nproposal\n');
220
+ await expect(readFile(trace.tracePath, 'utf-8')).resolves.toContain('patch_accepted_after_textual_resolution');
221
+ expect(await git.revParseHead()).not.toBe(baseSha);
222
+ });
223
+ it('keeps the pre-apply integration tree when the resolver cannot repair a textual conflict', async () => {
224
+ const { homeDir, configDir, git } = await makeRepo();
225
+ await mkdir(join(configDir, 'wiki/global'), { recursive: true });
226
+ await writeFile(join(configDir, 'wiki/global/a.md'), 'base\n', 'utf-8');
227
+ await git.commitFiles(['wiki/global/a.md'], 'base page', 'System User', 'system@example.com');
228
+ const conflictBase = await git.revParseHead();
229
+ await writeFile(join(configDir, 'wiki/global/a.md'), 'accepted\n', 'utf-8');
230
+ await git.commitFiles(['wiki/global/a.md'], 'accepted edit', 'System User', 'system@example.com');
231
+ const acceptedHead = await git.revParseHead();
232
+ const childDir = join(homeDir, 'child-conflict-fails');
233
+ await git.addWorktree(childDir, 'child-conflict-fails', conflictBase);
234
+ const childGit = git.forWorktree(childDir);
235
+ await writeFile(join(childDir, 'wiki/global/a.md'), 'proposal\n', 'utf-8');
236
+ await childGit.commitFiles(['wiki/global/a.md'], 'proposal edit', 'System User', 'system@example.com');
237
+ const patchPath = join(homeDir, 'proposal-fails.patch');
238
+ await childGit.writeBinaryNoRenamePatch(conflictBase, 'HEAD', patchPath);
239
+ const trace = new FileIngestTraceWriter({
240
+ tracePath: join(homeDir, '.ktx/ingest-traces/job-resolver-fails/trace.jsonl'),
241
+ jobId: 'job-resolver-fails',
242
+ connectionId: 'warehouse',
243
+ sourceKey: 'metabase',
244
+ level: 'trace',
245
+ });
246
+ const result = await integrateWorkUnitPatch({
247
+ unitKey: 'wu-conflict',
248
+ patchPath,
249
+ integrationGit: git,
250
+ trace,
251
+ author: { name: 'System User', email: 'system@example.com' },
252
+ slDisallowed: false,
253
+ allowedTargetConnectionIds: new Set(['warehouse']),
254
+ validateAppliedTree: vi.fn(async () => { }),
255
+ resolveTextualConflict: vi.fn(async () => ({
256
+ status: 'failed',
257
+ attempts: 1,
258
+ reason: 'resolver completed without editing an allowed path',
259
+ })),
260
+ });
261
+ expect(result).toMatchObject({
262
+ status: 'textual_conflict',
263
+ textualResolution: {
264
+ status: 'failed',
265
+ attempts: 1,
266
+ reason: 'resolver completed without editing an allowed path',
267
+ },
268
+ });
269
+ expect(await git.revParseHead()).toBe(acceptedHead);
270
+ await expect(readFile(join(configDir, 'wiki/global/a.md'), 'utf-8')).resolves.toBe('accepted\n');
271
+ });
272
+ it('repairs semantic gate failures after a patch applies cleanly', async () => {
273
+ const { homeDir, configDir, git, baseSha } = await makeRepo();
274
+ const childDir = join(homeDir, 'child-semantic-repair');
275
+ await git.addWorktree(childDir, 'child-semantic-repair', baseSha);
276
+ const childGit = git.forWorktree(childDir);
277
+ await writeFile(join(childDir, 'wiki/global/a.md'), 'bad semantic ref\n');
278
+ await childGit.commitFiles(['wiki/global/a.md'], 'bad semantic edit', 'System User', 'system@example.com');
279
+ const patchPath = join(homeDir, 'patches/semantic-repair.patch');
280
+ await childGit.writeBinaryNoRenamePatch(baseSha, 'HEAD', patchPath);
281
+ const trace = new FileIngestTraceWriter({
282
+ tracePath: join(homeDir, '.ktx/ingest-traces/job-semantic-repair/trace.jsonl'),
283
+ jobId: 'job-semantic-repair',
284
+ connectionId: 'c1',
285
+ sourceKey: 'fake',
286
+ level: 'trace',
287
+ });
288
+ const validateAppliedTree = vi
289
+ .fn()
290
+ .mockRejectedValueOnce(new Error('final artifact gates failed:\na: unknown semantic-layer entity'))
291
+ .mockResolvedValueOnce(undefined);
292
+ const result = await integrateWorkUnitPatch({
293
+ unitKey: 'wu-repairable',
294
+ patchPath,
295
+ integrationGit: git,
296
+ trace,
297
+ author: { name: 'KTX Test', email: 'system@ktx.local' },
298
+ validateAppliedTree,
299
+ slDisallowed: false,
300
+ allowedTargetConnectionIds: new Set(['c1']),
301
+ repairGateFailure: vi.fn(async (context) => {
302
+ expect(context).toMatchObject({
303
+ unitKey: 'wu-repairable',
304
+ patchPath,
305
+ touchedPaths: ['wiki/global/a.md'],
306
+ });
307
+ await writeFile(join(configDir, 'wiki/global/a.md'), 'repaired semantic ref\n', 'utf-8');
308
+ return {
309
+ status: 'repaired',
310
+ attempts: 1,
311
+ changedPaths: ['wiki/global/a.md'],
312
+ };
313
+ }),
314
+ });
315
+ expect(result).toMatchObject({
316
+ status: 'accepted',
317
+ touchedPaths: ['wiki/global/a.md'],
318
+ gateRepair: {
319
+ status: 'repaired',
320
+ attempts: 1,
321
+ changedPaths: ['wiki/global/a.md'],
322
+ },
323
+ });
324
+ expect(validateAppliedTree).toHaveBeenCalledTimes(2);
325
+ await expect(readFile(join(configDir, 'wiki/global/a.md'), 'utf-8')).resolves.toBe('repaired semantic ref\n');
326
+ await expect(readFile(trace.tracePath, 'utf-8')).resolves.toContain('patch_accepted_after_gate_repair');
327
+ });
328
+ it('keeps the pre-apply tree when semantic gate repair fails', async () => {
329
+ const { homeDir, configDir, git, baseSha } = await makeRepo();
330
+ const childDir = join(homeDir, 'child-semantic-repair-fails');
331
+ await git.addWorktree(childDir, 'child-semantic-repair-fails', baseSha);
332
+ const childGit = git.forWorktree(childDir);
333
+ await writeFile(join(childDir, 'wiki/global/a.md'), 'bad semantic ref\n');
334
+ await childGit.commitFiles(['wiki/global/a.md'], 'bad semantic edit', 'System User', 'system@example.com');
335
+ const patchPath = join(homeDir, 'patches/semantic-repair-fails.patch');
336
+ await childGit.writeBinaryNoRenamePatch(baseSha, 'HEAD', patchPath);
337
+ const trace = new FileIngestTraceWriter({
338
+ tracePath: join(homeDir, '.ktx/ingest-traces/job-semantic-repair-fails/trace.jsonl'),
339
+ jobId: 'job-semantic-repair-fails',
340
+ connectionId: 'c1',
341
+ sourceKey: 'fake',
342
+ level: 'trace',
343
+ });
344
+ const result = await integrateWorkUnitPatch({
345
+ unitKey: 'wu-not-repaired',
346
+ patchPath,
347
+ integrationGit: git,
348
+ trace,
349
+ author: { name: 'KTX Test', email: 'system@ktx.local' },
350
+ validateAppliedTree: vi.fn().mockRejectedValue(new Error('final artifact gates failed')),
351
+ slDisallowed: false,
352
+ allowedTargetConnectionIds: new Set(['c1']),
353
+ repairGateFailure: vi.fn(async () => ({
354
+ status: 'failed',
355
+ attempts: 1,
356
+ reason: 'gate repair completed without editing an allowed path',
357
+ })),
358
+ });
359
+ expect(result).toMatchObject({
360
+ status: 'semantic_conflict',
361
+ gateRepair: {
362
+ status: 'failed',
363
+ attempts: 1,
364
+ reason: 'gate repair completed without editing an allowed path',
365
+ },
366
+ });
367
+ await expect(readFile(join(configDir, 'wiki/global/a.md'), 'utf-8')).resolves.toBe('old\n');
368
+ });
369
+ });
@@ -0,0 +1,23 @@
1
+ import type { AgentRunnerPort } from '../../llm/index.js';
2
+ import type { IngestTraceWriter } from '../ingest-trace.js';
3
+ export type TextualConflictResolutionResult = {
4
+ status: 'repaired';
5
+ attempts: number;
6
+ changedPaths: string[];
7
+ } | {
8
+ status: 'failed';
9
+ attempts: number;
10
+ reason: string;
11
+ };
12
+ export interface ResolveTextualConflictInput {
13
+ agentRunner: AgentRunnerPort;
14
+ workdir: string;
15
+ unitKey: string;
16
+ patchPath: string;
17
+ touchedPaths: string[];
18
+ trace: IngestTraceWriter;
19
+ reason: string;
20
+ maxAttempts?: number;
21
+ stepBudget?: number;
22
+ }
23
+ export declare function resolveTextualConflict(input: ResolveTextualConflictInput): Promise<TextualConflictResolutionResult>;
@@ -0,0 +1,190 @@
1
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import { traceTimed } from '../ingest-trace.js';
5
+ const readIntegrationFileSchema = z.object({
6
+ path: z.string().min(1),
7
+ });
8
+ const writeIntegrationFileSchema = z.object({
9
+ path: z.string().min(1),
10
+ content: z.string(),
11
+ });
12
+ const deleteIntegrationFileSchema = z.object({
13
+ path: z.string().min(1),
14
+ });
15
+ function normalizeRepoPath(path) {
16
+ const normalized = path.replace(/\\/g, '/').replace(/^\/+/, '');
17
+ const parts = normalized.split('/').filter((part) => part.length > 0);
18
+ if (parts.length === 0 || parts.some((part) => part === '.' || part === '..')) {
19
+ throw new Error(`resolver path must be a repository-relative path: ${path}`);
20
+ }
21
+ return parts.join('/');
22
+ }
23
+ function assertAllowedPath(path, allowedPaths) {
24
+ const normalized = normalizeRepoPath(path);
25
+ if (!allowedPaths.has(normalized)) {
26
+ throw new Error(`resolver path not allowed: ${normalized}`);
27
+ }
28
+ return normalized;
29
+ }
30
+ async function readOptionalFile(path) {
31
+ try {
32
+ return { exists: true, content: await readFile(path, 'utf-8') };
33
+ }
34
+ catch (error) {
35
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
36
+ return { exists: false, content: '' };
37
+ }
38
+ throw error;
39
+ }
40
+ }
41
+ function buildResolverSystemPrompt() {
42
+ return `<role>
43
+ You repair one failed KTX isolated-diff patch inside the integration worktree.
44
+ </role>
45
+
46
+ <rules>
47
+ - Preserve accepted integration content that is unrelated to the failed patch.
48
+ - Incorporate the failed patch only when the patch evidence is compatible with the current file.
49
+ - Edit only paths exposed by the resolver tools.
50
+ - Prefer the smallest text edit that makes the composed artifact coherent.
51
+ - Do not create new facts that are absent from the current file or failed patch.
52
+ - Stop after writing the repaired file content.
53
+ </rules>`;
54
+ }
55
+ function buildResolverUserPrompt(input) {
56
+ return `Repair isolated-diff textual conflict.
57
+
58
+ WorkUnit: ${input.unitKey}
59
+ Attempt: ${input.attempt} of ${input.maxAttempts}
60
+ Patch path: ${input.patchPath}
61
+ Touched paths:
62
+ ${input.touchedPaths.map((path) => `- ${path}`).join('\n')}
63
+
64
+ Git apply failure:
65
+ ${input.reason}
66
+
67
+ Use read_failed_patch first. Then read the touched integration files, write the
68
+ repaired content, and stop.`;
69
+ }
70
+ function buildToolSet(input) {
71
+ return {
72
+ read_failed_patch: {
73
+ name: 'read_failed_patch',
74
+ description: 'Read the failed Git patch that could not be applied to the integration worktree.',
75
+ inputSchema: z.object({}),
76
+ execute: async () => {
77
+ const patch = await readFile(input.patchPath, 'utf-8');
78
+ return {
79
+ markdown: patch,
80
+ structured: { patchPath: input.patchPath, bytes: Buffer.byteLength(patch) },
81
+ };
82
+ },
83
+ },
84
+ read_integration_file: {
85
+ name: 'read_integration_file',
86
+ description: 'Read one allowed file from the current integration worktree.',
87
+ inputSchema: readIntegrationFileSchema,
88
+ execute: async ({ path }) => {
89
+ const normalized = assertAllowedPath(path, input.allowedPaths);
90
+ const file = await readOptionalFile(join(input.workdir, normalized));
91
+ return {
92
+ markdown: file.exists ? file.content : `(missing file: ${normalized})`,
93
+ structured: { path: normalized, exists: file.exists },
94
+ };
95
+ },
96
+ },
97
+ write_integration_file: {
98
+ name: 'write_integration_file',
99
+ description: 'Replace one allowed integration worktree file with repaired text content.',
100
+ inputSchema: writeIntegrationFileSchema,
101
+ execute: async ({ path, content }) => {
102
+ const normalized = assertAllowedPath(path, input.allowedPaths);
103
+ const fullPath = join(input.workdir, normalized);
104
+ await mkdir(dirname(fullPath), { recursive: true });
105
+ await writeFile(fullPath, content, 'utf-8');
106
+ input.editedPaths.add(normalized);
107
+ return {
108
+ markdown: `Wrote ${normalized}`,
109
+ structured: { path: normalized, bytes: Buffer.byteLength(content) },
110
+ };
111
+ },
112
+ },
113
+ delete_integration_file: {
114
+ name: 'delete_integration_file',
115
+ description: 'Delete one allowed integration worktree file when the failed patch proves the deletion is correct.',
116
+ inputSchema: deleteIntegrationFileSchema,
117
+ execute: async ({ path }) => {
118
+ const normalized = assertAllowedPath(path, input.allowedPaths);
119
+ await rm(join(input.workdir, normalized), { force: true });
120
+ input.editedPaths.add(normalized);
121
+ return {
122
+ markdown: `Deleted ${normalized}`,
123
+ structured: { path: normalized },
124
+ };
125
+ },
126
+ },
127
+ };
128
+ }
129
+ export async function resolveTextualConflict(input) {
130
+ const allowedPaths = new Set(input.touchedPaths.map(normalizeRepoPath));
131
+ const maxAttempts = input.maxAttempts ?? 1;
132
+ const stepBudget = input.stepBudget ?? 12;
133
+ let lastFailure = 'resolver did not run';
134
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
135
+ const editedPaths = new Set();
136
+ const traceData = {
137
+ unitKey: input.unitKey,
138
+ patchPath: input.patchPath,
139
+ touchedPaths: [...allowedPaths].sort(),
140
+ attempt,
141
+ maxAttempts,
142
+ reason: input.reason,
143
+ };
144
+ const result = await traceTimed(input.trace, 'resolver', 'textual_conflict_resolver', traceData, async () => input.agentRunner.runLoop({
145
+ modelRole: 'repair',
146
+ systemPrompt: buildResolverSystemPrompt(),
147
+ userPrompt: buildResolverUserPrompt({
148
+ unitKey: input.unitKey,
149
+ patchPath: input.patchPath,
150
+ touchedPaths: [...allowedPaths].sort(),
151
+ reason: input.reason,
152
+ attempt,
153
+ maxAttempts,
154
+ }),
155
+ toolSet: buildToolSet({
156
+ workdir: input.workdir,
157
+ patchPath: input.patchPath,
158
+ allowedPaths,
159
+ editedPaths,
160
+ }),
161
+ stepBudget,
162
+ telemetryTags: {
163
+ operationName: 'ingest-isolated-diff-textual-resolver',
164
+ source: input.trace.context.sourceKey,
165
+ jobId: input.trace.context.jobId,
166
+ unitKey: input.unitKey,
167
+ },
168
+ }));
169
+ if (result.stopReason === 'error') {
170
+ lastFailure = result.error?.message ?? 'resolver agent loop errored';
171
+ await input.trace.event('error', 'resolver', 'textual_conflict_resolver_failed', traceData, result.error);
172
+ continue;
173
+ }
174
+ const changedPaths = [...editedPaths].sort();
175
+ if (changedPaths.length === 0) {
176
+ lastFailure = 'resolver completed without editing an allowed path';
177
+ await input.trace.event('error', 'resolver', 'textual_conflict_resolver_failed', {
178
+ ...traceData,
179
+ reason: lastFailure,
180
+ });
181
+ continue;
182
+ }
183
+ await input.trace.event('debug', 'resolver', 'textual_conflict_resolver_repaired', {
184
+ ...traceData,
185
+ changedPaths,
186
+ });
187
+ return { status: 'repaired', attempts: attempt, changedPaths };
188
+ }
189
+ return { status: 'failed', attempts: maxAttempts, reason: lastFailure };
190
+ }