@hecer/yoke 1.13.0 → 1.15.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.
- package/CHANGELOG.md +39 -0
- package/README.md +16 -12
- package/canon/manifest.yaml +1 -1
- package/dist/cli.js +16 -2
- package/dist/code-intelligence/adapters/graft.js +11 -0
- package/dist/code-intelligence/adapters/graphify.js +8 -0
- package/dist/code-intelligence/adapters/index.js +5 -0
- package/dist/code-intelligence/adapters/mcp.js +37 -0
- package/dist/code-intelligence/adapters/serena.js +11 -0
- package/dist/code-intelligence/adapters/types.js +1 -0
- package/dist/code-intelligence/contracts.js +137 -0
- package/dist/code-intelligence/coordinator.js +370 -0
- package/dist/code-intelligence/edit-plans.js +53 -0
- package/dist/code-intelligence/evidence.js +77 -0
- package/dist/code-intelligence/index.js +5 -0
- package/dist/code-intelligence/internal-types.js +1 -0
- package/dist/code-intelligence/mcp-client.js +139 -0
- package/dist/code-intelligence/mcp-server.js +93 -0
- package/dist/code-intelligence/snapshots.js +117 -0
- package/dist/code-intelligence/transactions.js +60 -0
- package/dist/dashboard/analytics.js +193 -29
- package/dist/dashboard/contracts.js +23 -0
- package/dist/dashboard/page.js +39 -94
- package/dist/dashboard/panels.js +85 -31
- package/dist/dashboard/server.js +190 -15
- package/dist/observability/events.js +1 -1
- package/dist/observability/history.js +1 -1
- package/dist/retrofit/command.js +3 -1
- package/dist/retrofit/config.js +10 -0
- package/dist/retrofit/gitignore.js +1 -0
- package/dist/retrofit/plan.js +3 -3
- package/dist/retrofit/planners/claude.js +3 -3
- package/dist/retrofit/planners/codex.js +5 -5
- package/dist/retrofit/planners/gemini.js +2 -2
- package/dist/retrofit/planners/kilo.js +2 -2
- package/dist/retrofit/planners/opencode.js +2 -2
- package/dist/retrofit/planners/pi.js +1 -1
- package/dist/retrofit/planners/qwen.js +2 -2
- package/dist/retrofit/tools.js +9 -7
- package/dist/setup/command.js +3 -1
- package/docs/CODE-INTELLIGENCE.md +36 -0
- package/docs/DASHBOARD-EVOLUTION.md +16 -2
- package/docs/DASHBOARD-OVERHAUL.md +146 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { ContextInputSchema, EditApplyInputSchema, EditPreviewInputSchema, ImpactInputSchema, SymbolInputSchema, TraceInputSchema, } from './contracts.js';
|
|
5
|
+
import { createGraftAdapter, createGraphifyAdapter, createSerenaAdapter } from './adapters/index.js';
|
|
6
|
+
import { normalizeEdges, normalizeItems, normalizeSymbols } from './evidence.js';
|
|
7
|
+
import { assertSnapshotCurrent, createSnapshot, loadSnapshot, workspaceId } from './snapshots.js';
|
|
8
|
+
import { applyEditPlan } from './transactions.js';
|
|
9
|
+
import { createEditPlan, loadEditPlan } from './edit-plans.js';
|
|
10
|
+
function nowMs() { return Date.now(); }
|
|
11
|
+
function compact(value, maxBytes) {
|
|
12
|
+
const encoded = JSON.stringify(value) ?? '';
|
|
13
|
+
if (Buffer.byteLength(encoded) <= maxBytes)
|
|
14
|
+
return value;
|
|
15
|
+
return { truncated: true, excerpt: encoded.slice(0, Math.max(0, maxBytes - 64)) };
|
|
16
|
+
}
|
|
17
|
+
function statusFor(missing, hard = false) { return hard ? 'error' : missing.length ? 'partial' : 'success'; }
|
|
18
|
+
function errorCode(message) {
|
|
19
|
+
if (/outside|workspace|symbolic/u.test(message))
|
|
20
|
+
return 'WORKSPACE_DENIED';
|
|
21
|
+
if (/stale/u.test(message))
|
|
22
|
+
return 'SNAPSHOT_STALE';
|
|
23
|
+
if (/approval/u.test(message))
|
|
24
|
+
return 'APPROVAL_REQUIRED';
|
|
25
|
+
if (/expired/u.test(message))
|
|
26
|
+
return 'PLAN_EXPIRED';
|
|
27
|
+
if (/idempotency/u.test(message))
|
|
28
|
+
return 'IDEMPOTENCY_CONFLICT';
|
|
29
|
+
if (/transaction|lock|conflict/u.test(message))
|
|
30
|
+
return 'WRITE_CONFLICT';
|
|
31
|
+
if (/active|unavailable|MCP/u.test(message))
|
|
32
|
+
return 'CAPABILITY_UNAVAILABLE';
|
|
33
|
+
if (/scope|does not match/u.test(message))
|
|
34
|
+
return 'SCOPE_VIOLATION';
|
|
35
|
+
return 'INVALID_ARGUMENT';
|
|
36
|
+
}
|
|
37
|
+
function symbolArgs(symbol, fallbackPath = '') {
|
|
38
|
+
if (symbol.startsWith('serena:')) {
|
|
39
|
+
try {
|
|
40
|
+
const value = JSON.parse(decodeURIComponent(symbol.slice('serena:'.length)));
|
|
41
|
+
if (value.name_path)
|
|
42
|
+
return { name_path: value.name_path, relative_path: value.relative_path ?? fallbackPath };
|
|
43
|
+
}
|
|
44
|
+
catch { /* accept user-provided names below */ }
|
|
45
|
+
}
|
|
46
|
+
return { name_path: symbol, relative_path: fallbackPath };
|
|
47
|
+
}
|
|
48
|
+
export class CodeIntelligenceCoordinator {
|
|
49
|
+
root;
|
|
50
|
+
workspace_id;
|
|
51
|
+
options;
|
|
52
|
+
adapters;
|
|
53
|
+
constructor(root, options = {}) {
|
|
54
|
+
this.root = root;
|
|
55
|
+
this.workspace_id = workspaceId(root);
|
|
56
|
+
this.options = options;
|
|
57
|
+
this.adapters = {
|
|
58
|
+
graft: options.adapters?.graft ?? createGraftAdapter(root, options.graft),
|
|
59
|
+
graphify: options.adapters?.graphify ?? createGraphifyAdapter(root, options.graphify),
|
|
60
|
+
'serena-lsp': options.adapters?.['serena-lsp'] ?? createSerenaAdapter(root, options.serena),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async close() { await Promise.all(Object.values(this.adapters).map(adapter => adapter.close())); }
|
|
64
|
+
async dispatch(name, input) {
|
|
65
|
+
const requestId = randomUUID();
|
|
66
|
+
const started = nowMs();
|
|
67
|
+
const suppliedWorkspace = typeof input === 'object' && input !== null && typeof input.workspace_id === 'string' ? input.workspace_id : this.workspace_id;
|
|
68
|
+
let snapshot;
|
|
69
|
+
try {
|
|
70
|
+
snapshot = this.snapshotFor(input);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return this.errorResponse(requestId, suppliedWorkspace, this.safeSnapshotId(), started, error);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
let result;
|
|
77
|
+
switch (name) {
|
|
78
|
+
case 'code_context':
|
|
79
|
+
result = await this.context(ContextInputSchema.parse(input), snapshot);
|
|
80
|
+
break;
|
|
81
|
+
case 'code_symbol':
|
|
82
|
+
result = await this.symbol(SymbolInputSchema.parse(input), snapshot);
|
|
83
|
+
break;
|
|
84
|
+
case 'code_trace':
|
|
85
|
+
result = await this.trace(TraceInputSchema.parse(input), snapshot);
|
|
86
|
+
break;
|
|
87
|
+
case 'code_impact':
|
|
88
|
+
result = await this.impact(ImpactInputSchema.parse(input), snapshot);
|
|
89
|
+
break;
|
|
90
|
+
case 'code_edit_preview':
|
|
91
|
+
result = await this.preview(EditPreviewInputSchema.parse(input), snapshot);
|
|
92
|
+
break;
|
|
93
|
+
case 'code_edit_apply':
|
|
94
|
+
result = await this.apply(EditApplyInputSchema.parse(input), snapshot);
|
|
95
|
+
break;
|
|
96
|
+
default: throw new Error(`unknown code-intelligence tool: ${name}`);
|
|
97
|
+
}
|
|
98
|
+
const bytes = Buffer.byteLength(JSON.stringify(result.data) ?? '');
|
|
99
|
+
const maxBytes = this.options.limits?.maxBytes ?? 2_000_000;
|
|
100
|
+
return {
|
|
101
|
+
schema_version: '0.1.0', request_id: requestId, workspace_id: this.workspace_id, snapshot_id: snapshot.snapshot_id,
|
|
102
|
+
status: statusFor(result.missing, result.hard), data: compact(result.data, maxBytes), coverage: this.coverage(result.used, result.missing), provenance: result.provenance,
|
|
103
|
+
warnings: result.warnings, metrics: { latency_ms: nowMs() - started, result_tokens: Math.ceil(bytes / 4), token_count_kind: 'estimated', returned_bytes: Math.min(bytes, maxBytes) }, artifact_uri: null, error: null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return this.errorResponse(requestId, this.workspace_id, snapshot.snapshot_id, started, error);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
snapshotFor(input) {
|
|
111
|
+
const candidate = input && typeof input === 'object' ? input : {};
|
|
112
|
+
if (candidate.workspace_id && candidate.workspace_id !== this.workspace_id)
|
|
113
|
+
throw new Error('workspace is not permitted');
|
|
114
|
+
const snapshot = candidate.snapshot_id ? loadSnapshot(this.root, candidate.snapshot_id) : createSnapshot(this.root, this.options.policy, this.workspace_id);
|
|
115
|
+
if (candidate.snapshot_id)
|
|
116
|
+
assertSnapshotCurrent(this.root, snapshot, this.options.policy);
|
|
117
|
+
return snapshot;
|
|
118
|
+
}
|
|
119
|
+
safeSnapshotId() {
|
|
120
|
+
try {
|
|
121
|
+
return createSnapshot(this.root, this.options.policy, this.workspace_id).snapshot_id;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return '0'.repeat(64);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
coverage(used, missing) {
|
|
128
|
+
return {
|
|
129
|
+
backends_requested: [...new Set([...used, ...missing])], backends_used: [...new Set(used)], backends_missing: [...new Set(missing)],
|
|
130
|
+
structural: used.includes('graft') ? 'complete' : 'unavailable',
|
|
131
|
+
semantic: used.includes('serena-lsp') ? 'complete' : 'unavailable',
|
|
132
|
+
documents: used.includes('graphify') ? 'complete' : 'unavailable',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
async call(backend, tool, args, timeout, state) {
|
|
136
|
+
const seen = new Set([...state.used, ...state.missing]);
|
|
137
|
+
const maxBackends = this.options.limits?.maxBackends ?? 3;
|
|
138
|
+
if (!seen.has(backend) && seen.size >= maxBackends) {
|
|
139
|
+
state.missing.push(backend);
|
|
140
|
+
state.warnings.push(`${backend}: backend budget exceeded`);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const value = await this.adapters[backend].call({ tool, arguments: args }, Math.min(timeout, this.options.limits?.timeoutMs ?? timeout));
|
|
145
|
+
if (!state.used.includes(backend))
|
|
146
|
+
state.used.push(backend);
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (!state.missing.includes(backend))
|
|
151
|
+
state.missing.push(backend);
|
|
152
|
+
state.warnings.push(`${backend}: ${error.message}`);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
evidenceOptions(snapshot, backend, maxChars = 12000) {
|
|
157
|
+
return { root: this.root, snapshot, adapter: this.adapters[backend], maxChars };
|
|
158
|
+
}
|
|
159
|
+
async context(input, snapshot) {
|
|
160
|
+
const state = { used: [], missing: [], warnings: [] };
|
|
161
|
+
const provenance = [];
|
|
162
|
+
let items = [];
|
|
163
|
+
const raw = await this.call('graft', 'context', { query: input.query, limit: Math.max(1, Math.floor(input.token_budget / 300)), ...(input.paths[0] ? { in: input.paths[0] } : {}) }, input.timeout_ms, state);
|
|
164
|
+
if (raw !== null) {
|
|
165
|
+
const normalized = normalizeItems(raw, this.evidenceOptions(snapshot, 'graft'), 'code', 100);
|
|
166
|
+
items.push(...normalized.items);
|
|
167
|
+
provenance.push(...normalized.provenance);
|
|
168
|
+
}
|
|
169
|
+
if (input.include_docs) {
|
|
170
|
+
const docs = await this.call('graphify', 'context', { question: input.query, depth: 3, token_budget: input.token_budget }, input.timeout_ms, state);
|
|
171
|
+
if (docs !== null) {
|
|
172
|
+
const normalized = normalizeItems(docs, this.evidenceOptions(snapshot, 'graphify'), 'document', 50);
|
|
173
|
+
items.push(...normalized.items);
|
|
174
|
+
provenance.push(...normalized.provenance);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { data: { items, next_cursor: null }, provenance, ...state, warnings: state.warnings };
|
|
178
|
+
}
|
|
179
|
+
async symbol(input, snapshot) {
|
|
180
|
+
const state = { used: [], missing: [], warnings: [] };
|
|
181
|
+
const provenance = [];
|
|
182
|
+
let symbols = [];
|
|
183
|
+
let references = [];
|
|
184
|
+
let diagnostics = [];
|
|
185
|
+
const name = input.symbol_id ?? input.query;
|
|
186
|
+
const reference = symbolArgs(name, input.path ?? '');
|
|
187
|
+
const graft = await this.call('graft', 'context', { query: name, limit: 10, ...(input.path ? { in: input.path } : {}) }, input.timeout_ms, state);
|
|
188
|
+
if (graft !== null) {
|
|
189
|
+
const normalized = normalizeItems(graft, this.evidenceOptions(snapshot, 'graft'), 'symbol', 20);
|
|
190
|
+
symbols.push(...normalized.items.map(item => ({ symbol_id: `graft:${item.item_id}`, name: item.excerpt.split(/\s|\(|\{/u)[0] ?? name, path: item.path, kind: 'unknown', signature: item.excerpt.slice(0, 500), evidence_ids: item.evidence_ids })));
|
|
191
|
+
provenance.push(...normalized.provenance);
|
|
192
|
+
}
|
|
193
|
+
const symbol = await this.call('serena-lsp', 'symbol', { name_path_pattern: reference.name_path, depth: 0, relative_path: reference.relative_path, include_body: false, max_matches: 20 }, input.timeout_ms, state);
|
|
194
|
+
if (symbol !== null) {
|
|
195
|
+
const normalized = normalizeSymbols(symbol, this.evidenceOptions(snapshot, 'serena-lsp'));
|
|
196
|
+
symbols.push(...normalized.symbols);
|
|
197
|
+
provenance.push(...normalized.provenance);
|
|
198
|
+
}
|
|
199
|
+
if (input.include.includes('references')) {
|
|
200
|
+
const raw = await this.call('serena-lsp', 'references', reference, input.timeout_ms, state);
|
|
201
|
+
if (raw !== null) {
|
|
202
|
+
const normalized = normalizeItems(raw, this.evidenceOptions(snapshot, 'serena-lsp'), 'code', 100);
|
|
203
|
+
references = normalized.items.map(item => ({ symbol_id: input.symbol_id ?? `symbol-${item.item_id.slice(5)}`, path: item.path, line: null, excerpt: item.excerpt, evidence_ids: item.evidence_ids }));
|
|
204
|
+
provenance.push(...normalized.provenance);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (input.include.includes('implementations')) {
|
|
208
|
+
const raw = await this.call('serena-lsp', 'implementations', reference, input.timeout_ms, state);
|
|
209
|
+
if (raw !== null) {
|
|
210
|
+
const normalized = normalizeSymbols(raw, this.evidenceOptions(snapshot, 'serena-lsp'));
|
|
211
|
+
symbols.push(...normalized.symbols);
|
|
212
|
+
provenance.push(...normalized.provenance);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (input.include.includes('diagnostics')) {
|
|
216
|
+
const raw = await this.call('serena-lsp', 'diagnostics', { relative_path: reference.relative_path }, input.timeout_ms, state);
|
|
217
|
+
if (raw !== null) {
|
|
218
|
+
const normalized = normalizeItems(raw, this.evidenceOptions(snapshot, 'serena-lsp'), 'code', 100);
|
|
219
|
+
diagnostics = normalized.items.map(item => ({ path: item.path, line: null, severity: 'unknown', message: item.excerpt, evidence_ids: item.evidence_ids }));
|
|
220
|
+
provenance.push(...normalized.provenance);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { data: { symbols, references, diagnostics }, provenance, ...state, warnings: state.warnings };
|
|
224
|
+
}
|
|
225
|
+
async trace(input, snapshot) {
|
|
226
|
+
const state = { used: [], missing: [], warnings: [] };
|
|
227
|
+
const provenance = [];
|
|
228
|
+
let nodes = [];
|
|
229
|
+
let edges = [];
|
|
230
|
+
const raw = await this.call('graft', 'trace', { symbol: input.symbol_id, direction: input.direction, depth: input.max_depth }, input.timeout_ms, state);
|
|
231
|
+
if (raw !== null) {
|
|
232
|
+
const normalized = normalizeEdges(raw, this.evidenceOptions(snapshot, 'graft'), input.relations[0] ?? 'references');
|
|
233
|
+
nodes.push(...normalized.nodes);
|
|
234
|
+
edges.push(...normalized.edges);
|
|
235
|
+
provenance.push(...normalized.provenance);
|
|
236
|
+
}
|
|
237
|
+
if (input.require_resolved || input.relations.some(relation => relation === 'implements' || relation === 'extends')) {
|
|
238
|
+
const rawSemantic = await this.call('serena-lsp', 'references', symbolArgs(input.symbol_id), input.timeout_ms, state);
|
|
239
|
+
if (rawSemantic !== null) {
|
|
240
|
+
const normalized = normalizeEdges(rawSemantic, this.evidenceOptions(snapshot, 'serena-lsp'), 'references');
|
|
241
|
+
nodes.push(...normalized.nodes);
|
|
242
|
+
edges.push(...normalized.edges);
|
|
243
|
+
provenance.push(...normalized.provenance);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
nodes = [...new Map(nodes.map(node => [node.id, node])).values()].slice(0, input.max_nodes);
|
|
247
|
+
edges = edges.filter(edge => nodes.some(node => node.id === edge.from) && nodes.some(node => node.id === edge.to));
|
|
248
|
+
return { data: { nodes, edges, frontier_remaining: Math.max(0, edges.length - nodes.length) }, provenance, ...state, warnings: state.warnings };
|
|
249
|
+
}
|
|
250
|
+
async impact(input, snapshot) {
|
|
251
|
+
const state = { used: [], missing: [], warnings: [] };
|
|
252
|
+
const provenance = [];
|
|
253
|
+
const structural = [];
|
|
254
|
+
const semantic = [];
|
|
255
|
+
const documents = [];
|
|
256
|
+
const unresolved = [];
|
|
257
|
+
let targets = input.targets;
|
|
258
|
+
if (!targets && input.plan_id) {
|
|
259
|
+
const plan = loadEditPlan(this.root, input.plan_id);
|
|
260
|
+
if (plan.workspace_id !== this.workspace_id)
|
|
261
|
+
throw new Error('impact plan belongs to another workspace');
|
|
262
|
+
targets = plan.changed_paths.map(path => ({ path }));
|
|
263
|
+
}
|
|
264
|
+
targets ??= [];
|
|
265
|
+
for (const target of targets) {
|
|
266
|
+
const value = 'path' in target ? target.path : target.symbol_id;
|
|
267
|
+
const raw = await this.call('graft', 'context', { query: `impact of ${value}`, limit: 20, ...('path' in target ? { in: target.path } : {}) }, input.timeout_ms, state);
|
|
268
|
+
if (raw !== null) {
|
|
269
|
+
const normalized = normalizeItems(raw, this.evidenceOptions(snapshot, 'graft'), 'code', 50);
|
|
270
|
+
structural.push(...normalized.items);
|
|
271
|
+
provenance.push(...normalized.provenance);
|
|
272
|
+
}
|
|
273
|
+
else
|
|
274
|
+
unresolved.push(value);
|
|
275
|
+
if (input.require_semantic) {
|
|
276
|
+
const rawSemantic = 'symbol_id' in target
|
|
277
|
+
? await this.call('serena-lsp', 'references', symbolArgs(target.symbol_id), input.timeout_ms, state)
|
|
278
|
+
: await this.call('serena-lsp', 'overview', { relative_path: target.path, depth: 2 }, input.timeout_ms, state);
|
|
279
|
+
if (rawSemantic !== null) {
|
|
280
|
+
const normalized = normalizeItems(rawSemantic, this.evidenceOptions(snapshot, 'serena-lsp'), 'symbol', 50);
|
|
281
|
+
semantic.push(...normalized.items);
|
|
282
|
+
provenance.push(...normalized.provenance);
|
|
283
|
+
}
|
|
284
|
+
else
|
|
285
|
+
unresolved.push('symbol_id' in target ? target.symbol_id : target.path);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (input.include_docs) {
|
|
289
|
+
const docs = await this.call('graphify', 'context', { question: `architecture impact ${JSON.stringify(targets)}`, depth: input.max_depth, token_budget: 2000 }, input.timeout_ms, state);
|
|
290
|
+
if (docs !== null) {
|
|
291
|
+
const normalized = normalizeItems(docs, this.evidenceOptions(snapshot, 'graphify'), 'document', 50);
|
|
292
|
+
documents.push(...normalized.items);
|
|
293
|
+
provenance.push(...normalized.provenance);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const suggested = [...new Set(snapshot.files.map(file => file.path).filter(path => /(^|\/)(test|tests|__tests__|spec)(\/|\.)/u.test(path)))].slice(0, 20);
|
|
297
|
+
return { data: { semantic_findings: semantic, structural_candidates: structural, documents, suggested_test_paths: suggested, unresolved }, provenance, ...state, warnings: state.warnings };
|
|
298
|
+
}
|
|
299
|
+
async preview(input, snapshot) {
|
|
300
|
+
if (this.options.mode !== 'active')
|
|
301
|
+
throw new Error('code edits require code-intelligence mode=active');
|
|
302
|
+
const state = { used: [], missing: [], warnings: [] };
|
|
303
|
+
const provenance = [];
|
|
304
|
+
const sandbox = join(this.root, '.yoke', 'code-intelligence', 'worktrees', `preview-${randomUUID()}`);
|
|
305
|
+
mkdirSync(sandbox, { recursive: true });
|
|
306
|
+
for (const file of snapshot.files) {
|
|
307
|
+
const source = join(this.root, file.path);
|
|
308
|
+
const target = join(sandbox, file.path);
|
|
309
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
310
|
+
copyFileSync(source, target);
|
|
311
|
+
}
|
|
312
|
+
const adapter = createSerenaAdapter(sandbox, this.options.serena);
|
|
313
|
+
const changed = new Set();
|
|
314
|
+
try {
|
|
315
|
+
for (const operation of input.operations) {
|
|
316
|
+
const reference = symbolArgs(operation.symbol_id);
|
|
317
|
+
const args = 'new_name' in operation ? { ...reference, new_name: operation.new_name } : operation.kind === 'safe_delete' ? { name_path_pattern: reference.name_path, relative_path: reference.relative_path } : { ...reference, body: 'content' in operation ? operation.content : '' };
|
|
318
|
+
const tool = operation.kind === 'safe_delete' ? 'safe_delete' : operation.kind;
|
|
319
|
+
try {
|
|
320
|
+
await adapter.call({ tool, arguments: args }, input.timeout_ms);
|
|
321
|
+
state.used.push('serena-lsp');
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
state.missing.push('serena-lsp');
|
|
325
|
+
state.warnings.push(`serena-lsp: ${error.message}`);
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
await adapter.close();
|
|
332
|
+
}
|
|
333
|
+
const diffs = [];
|
|
334
|
+
for (const file of snapshot.files) {
|
|
335
|
+
const before = readFileSync(join(this.root, file.path));
|
|
336
|
+
const afterPath = join(sandbox, file.path);
|
|
337
|
+
if (!existsSync(afterPath)) {
|
|
338
|
+
changed.add(file.path);
|
|
339
|
+
diffs.push(`--- a/${file.path}\n+++ /dev/null\n`);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const after = readFileSync(afterPath);
|
|
343
|
+
if (!before.equals(after)) {
|
|
344
|
+
changed.add(file.path);
|
|
345
|
+
diffs.push(`--- a/${file.path}\n+++ b/${file.path}\n@@\n-${before.toString('utf8')}\n+${after.toString('utf8')}\n`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const diff = diffs.join('\n').slice(0, this.options.limits?.maxBytes ?? 2_000_000);
|
|
349
|
+
const plan = createEditPlan(this.root, snapshot, sandbox, input.operations, state.missing.length ? 'incomplete' : 'passed', input.validation_profile, [...changed], diff);
|
|
350
|
+
return { data: plan, provenance, ...state, warnings: state.warnings };
|
|
351
|
+
}
|
|
352
|
+
async apply(input, snapshot) {
|
|
353
|
+
if (this.options.mode !== 'active')
|
|
354
|
+
throw new Error('code edits require code-intelligence mode=active');
|
|
355
|
+
const state = { used: [], missing: [], warnings: [] };
|
|
356
|
+
const plan = loadEditPlan(this.root, input.plan_id);
|
|
357
|
+
if (plan.workspace_id !== this.workspace_id || plan.snapshot_id !== input.expected_snapshot_id)
|
|
358
|
+
throw new Error('edit plan scope does not match workspace or expected snapshot');
|
|
359
|
+
const expected = loadSnapshot(this.root, input.expected_snapshot_id);
|
|
360
|
+
assertSnapshotCurrent(this.root, expected, this.options.policy);
|
|
361
|
+
const result = applyEditPlan(this.root, plan, expected, input.idempotency_key);
|
|
362
|
+
return { data: { plan_id: result.plan_id, transaction_id: result.transaction_id, changed_paths: result.changed_paths, new_snapshot_id: result.new_snapshot_id, validation: result.validation, committed: false, merged: false }, provenance: [], ...state, warnings: state.warnings };
|
|
363
|
+
}
|
|
364
|
+
errorResponse(requestId, workspace, snapshotId, started, error) {
|
|
365
|
+
const code = errorCode(error.message);
|
|
366
|
+
const blocked = ['WORKSPACE_DENIED', 'SNAPSHOT_STALE', 'APPROVAL_REQUIRED', 'PLAN_EXPIRED', 'CAPABILITY_UNAVAILABLE', 'SCOPE_VIOLATION', 'WRITE_CONFLICT'].includes(code);
|
|
367
|
+
return { schema_version: '0.1.0', request_id: requestId, workspace_id: workspace, snapshot_id: snapshotId, status: blocked ? 'blocked' : 'error', data: null, coverage: { backends_requested: [], backends_used: [], backends_missing: [], structural: 'unavailable', semantic: 'unavailable', documents: 'unavailable' }, provenance: [], warnings: [], metrics: { latency_ms: nowMs() - started, result_tokens: 0, token_count_kind: 'exact', returned_bytes: 0 }, artifact_uri: null, error: { code, message: error.message } };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
export function createCodeIntelligenceCoordinator(root, options) { return new CodeIntelligenceCoordinator(root, options); }
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
function hash(value) { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); }
|
|
5
|
+
function storeDir(root) { return join(root, '.yoke', 'code-intelligence', 'plans'); }
|
|
6
|
+
function planHashInput(plan) {
|
|
7
|
+
return { workspace_id: plan.workspace_id, snapshot_id: plan.snapshot_id, root: plan.root, sandbox_root: plan.sandbox_root, operations: plan.operations, validation_profile: plan.validation_profile, changedPaths: plan.changed_paths, validation: plan.validation, expiresAt: plan.expires_at, diffHash: plan.diff_hash, sandboxHash: plan.sandbox_hash };
|
|
8
|
+
}
|
|
9
|
+
export function createEditPlan(root, snapshot, sandboxRoot, operations, validation, validationProfile, changedPaths, diff, ttlMs = 30 * 60_000) {
|
|
10
|
+
const planId = `plan-${randomUUID()}`;
|
|
11
|
+
const diffUri = join(storeDir(root), `${planId}.diff`);
|
|
12
|
+
const expiresAt = new Date(Date.now() + ttlMs).toISOString();
|
|
13
|
+
const diffHash = hash(diff);
|
|
14
|
+
const sandboxHash = hash(changedPaths.map(path => [path, existsSync(join(sandboxRoot, path)) ? readFileSync(join(sandboxRoot, path)).toString('base64') : null]));
|
|
15
|
+
const base = { workspace_id: snapshot.workspace_id, snapshot_id: snapshot.snapshot_id, root, sandbox_root: sandboxRoot, operations, validation_profile: validationProfile, changedPaths, validation, expiresAt, diffHash, sandboxHash };
|
|
16
|
+
const planHash = hash(base);
|
|
17
|
+
const plan = {
|
|
18
|
+
plan_id: planId, plan_hash: planHash, diff_uri: diffUri, changed_paths: changedPaths, validation, approval_required: true, expires_at: expiresAt,
|
|
19
|
+
workspace_id: snapshot.workspace_id, snapshot_id: snapshot.snapshot_id, root, sandbox_root: sandboxRoot, operations, validation_profile: validationProfile, created_at: new Date().toISOString(), diff_hash: diffHash, sandbox_hash: sandboxHash,
|
|
20
|
+
};
|
|
21
|
+
mkdirSync(storeDir(root), { recursive: true });
|
|
22
|
+
writeFileSync(diffUri, diff, { mode: 0o600 });
|
|
23
|
+
writeFileSync(join(storeDir(root), `${planId}.json`), JSON.stringify(plan, null, 2) + '\n', { mode: 0o600 });
|
|
24
|
+
return plan;
|
|
25
|
+
}
|
|
26
|
+
export function loadEditPlan(root, planId) {
|
|
27
|
+
const file = join(storeDir(root), `${planId}.json`);
|
|
28
|
+
if (!existsSync(file))
|
|
29
|
+
throw new Error(`unknown edit plan: ${planId}`);
|
|
30
|
+
const plan = JSON.parse(readFileSync(file, 'utf8'));
|
|
31
|
+
if (plan.plan_hash !== hash(planHashInput(plan)))
|
|
32
|
+
throw new Error('edit plan integrity check failed');
|
|
33
|
+
return plan;
|
|
34
|
+
}
|
|
35
|
+
/** This is deliberately separate from the MCP apply tool. The loop/control layer
|
|
36
|
+
* calls it only after human/policy approval has been recorded. */
|
|
37
|
+
export function approveEditPlan(root, planId) {
|
|
38
|
+
const plan = loadEditPlan(root, planId);
|
|
39
|
+
mkdirSync(storeDir(root), { recursive: true });
|
|
40
|
+
writeFileSync(join(storeDir(root), `${planId}.approval.json`), JSON.stringify({ plan_id: planId, plan_hash: plan.plan_hash, approved_at: new Date().toISOString(), authority: 'yoke-control' }) + '\n', { mode: 0o600 });
|
|
41
|
+
}
|
|
42
|
+
export function readPlanApproval(root, plan) {
|
|
43
|
+
const file = join(storeDir(root), `${plan.plan_id}.approval.json`);
|
|
44
|
+
if (!existsSync(file))
|
|
45
|
+
return false;
|
|
46
|
+
try {
|
|
47
|
+
const approval = JSON.parse(readFileSync(file, 'utf8'));
|
|
48
|
+
return approval.plan_hash === plan.plan_hash && approval.authority === 'yoke-control';
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { assertSafePath } from './snapshots.js';
|
|
3
|
+
function hash(value) { return createHash('sha256').update(value).digest('hex'); }
|
|
4
|
+
function textOf(raw) {
|
|
5
|
+
if (typeof raw === 'string')
|
|
6
|
+
return raw;
|
|
7
|
+
try {
|
|
8
|
+
return JSON.stringify(raw) ?? '';
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return String(raw);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function locate(text, root) {
|
|
15
|
+
const match = /(?:^|[\s("'`])((?:[A-Za-z0-9_.@-]+[\\/])*[A-Za-z0-9_.@-]+\.[A-Za-z0-9]+):(\d+)/u.exec(text);
|
|
16
|
+
if (!match)
|
|
17
|
+
return { path: null, line: null };
|
|
18
|
+
try {
|
|
19
|
+
return { path: assertSafePath(root, match[1]), line: Number(match[2]) };
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return { path: null, line: null };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function makeProvenance(options, text, resolution = 'resolved') {
|
|
26
|
+
const location = locate(text, options.root);
|
|
27
|
+
const file = location.path ? options.snapshot.files.find(item => item.path === location.path) : undefined;
|
|
28
|
+
return {
|
|
29
|
+
backend: options.adapter.name, version: options.adapter.version, source_path: location.path, content_hash: file?.hash ?? null,
|
|
30
|
+
byte_range: null, origin: options.origin ?? (options.adapter.semantic ? 'lsp' : 'parser'), resolution, freshness: 'current',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function evidenceId(provenance, excerpt) {
|
|
34
|
+
return `evidence-${hash(JSON.stringify({ provenance, excerpt })).slice(0, 24)}`;
|
|
35
|
+
}
|
|
36
|
+
export function normalizeItems(raw, options, kind = 'code', limit = 100) {
|
|
37
|
+
const full = textOf(raw);
|
|
38
|
+
const chunks = Array.isArray(raw) ? raw : (raw && typeof raw === 'object' && Array.isArray(raw.results) ? raw.results : [raw]);
|
|
39
|
+
const provenance = [];
|
|
40
|
+
const items = [];
|
|
41
|
+
const maxChars = options.maxChars ?? 12000;
|
|
42
|
+
for (const candidate of chunks.slice(0, limit)) {
|
|
43
|
+
const excerpt = textOf(candidate).slice(0, maxChars);
|
|
44
|
+
if (!excerpt)
|
|
45
|
+
continue;
|
|
46
|
+
const p = makeProvenance(options, excerpt, excerpt.toLowerCase().includes('ambiguous') ? 'ambiguous' : 'resolved');
|
|
47
|
+
const id = evidenceId(p, excerpt);
|
|
48
|
+
provenance.push(p);
|
|
49
|
+
items.push({ item_id: `item-${hash(excerpt).slice(0, 24)}`, kind, path: p.source_path, excerpt, evidence_ids: [id], rank: items.length });
|
|
50
|
+
}
|
|
51
|
+
if (items.length === 0 && full) {
|
|
52
|
+
const excerpt = full.slice(0, maxChars);
|
|
53
|
+
const p = makeProvenance(options, excerpt, 'unresolved');
|
|
54
|
+
const id = evidenceId(p, excerpt);
|
|
55
|
+
provenance.push(p);
|
|
56
|
+
items.push({ item_id: `item-${hash(excerpt).slice(0, 24)}`, kind, path: p.source_path, excerpt, evidence_ids: [id], rank: 0 });
|
|
57
|
+
}
|
|
58
|
+
return { items, provenance };
|
|
59
|
+
}
|
|
60
|
+
export function normalizeSymbols(raw, options) {
|
|
61
|
+
const normalized = normalizeItems(raw, options, 'symbol');
|
|
62
|
+
const candidates = Array.isArray(raw) ? raw : (raw && typeof raw === 'object' && Array.isArray(raw.results) ? raw.results : []);
|
|
63
|
+
const symbols = normalized.items.map((item, index) => {
|
|
64
|
+
const candidate = candidates[index] && typeof candidates[index] === 'object' ? candidates[index] : {};
|
|
65
|
+
const namePath = String(candidate.name_path ?? candidate.namePath ?? candidate.name ?? item.excerpt.split(/\s|\(|\{/u)[0] ?? 'unknown');
|
|
66
|
+
const path = typeof candidate.relative_path === 'string' ? candidate.relative_path : item.path;
|
|
67
|
+
const stableId = `serena:${encodeURIComponent(JSON.stringify({ name_path: namePath, relative_path: path ?? '' }))}`;
|
|
68
|
+
return { symbol_id: stableId, name: String(candidate.name ?? namePath.split('/').at(-1) ?? namePath), path, kind: String(candidate.kind ?? 'unknown'), signature: item.excerpt.slice(0, 500), evidence_ids: item.evidence_ids };
|
|
69
|
+
});
|
|
70
|
+
return { symbols, references: [], diagnostics: [], provenance: normalized.provenance };
|
|
71
|
+
}
|
|
72
|
+
export function normalizeEdges(raw, options, relation) {
|
|
73
|
+
const normalized = normalizeItems(raw, options, 'code');
|
|
74
|
+
const nodes = normalized.items.map(item => ({ id: item.item_id, label: item.excerpt.slice(0, 160), path: item.path, evidence_ids: item.evidence_ids }));
|
|
75
|
+
const edges = nodes.slice(1).map((node, index) => ({ from: nodes[index].id, to: node.id, relation, evidence_ids: node.evidence_ids }));
|
|
76
|
+
return { nodes, edges, provenance: normalized.provenance };
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
/** Small, bounded stdio MCP client. It supports Graft's newline JSON-RPC and the
|
|
3
|
+
* Content-Length framing used by the Python MCP SDK (Graphify and Serena). */
|
|
4
|
+
export class McpStdioClient {
|
|
5
|
+
command;
|
|
6
|
+
args;
|
|
7
|
+
cwd;
|
|
8
|
+
framing;
|
|
9
|
+
maxBytes;
|
|
10
|
+
child;
|
|
11
|
+
buffer = Buffer.alloc(0);
|
|
12
|
+
pending = new Map();
|
|
13
|
+
nextId = 1;
|
|
14
|
+
stderrBytes = 0;
|
|
15
|
+
constructor(command, args, cwd, framing = 'content-length', maxBytes = 2_000_000) {
|
|
16
|
+
this.command = command;
|
|
17
|
+
this.args = args;
|
|
18
|
+
this.cwd = cwd;
|
|
19
|
+
this.framing = framing;
|
|
20
|
+
this.maxBytes = maxBytes;
|
|
21
|
+
}
|
|
22
|
+
async call(tool, arguments_, timeoutMs) {
|
|
23
|
+
await this.start(timeoutMs);
|
|
24
|
+
return this.request('tools/call', { name: tool, arguments: arguments_ }, timeoutMs);
|
|
25
|
+
}
|
|
26
|
+
async close() {
|
|
27
|
+
if (!this.child)
|
|
28
|
+
return;
|
|
29
|
+
const child = this.child;
|
|
30
|
+
this.child = undefined;
|
|
31
|
+
for (const pending of this.pending.values()) {
|
|
32
|
+
clearTimeout(pending.timer);
|
|
33
|
+
pending.reject(new Error('MCP process closed'));
|
|
34
|
+
}
|
|
35
|
+
this.pending.clear();
|
|
36
|
+
child.kill('SIGTERM');
|
|
37
|
+
await new Promise(resolve => {
|
|
38
|
+
const timer = setTimeout(() => { if (!child.killed)
|
|
39
|
+
child.kill('SIGKILL'); resolve(); }, 500);
|
|
40
|
+
child.once('close', () => { clearTimeout(timer); resolve(); });
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
async start(timeoutMs) {
|
|
44
|
+
if (this.child)
|
|
45
|
+
return;
|
|
46
|
+
const child = spawn(this.command, this.args, { cwd: this.cwd, shell: false, stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, NO_COLOR: '1' } });
|
|
47
|
+
this.child = child;
|
|
48
|
+
child.stdout.on('data', chunk => this.consume(Buffer.from(chunk)));
|
|
49
|
+
child.stderr.on('data', chunk => { this.stderrBytes += Buffer.byteLength(chunk); if (this.stderrBytes > this.maxBytes)
|
|
50
|
+
void this.close(); });
|
|
51
|
+
child.on('error', error => this.fail(new Error(`MCP backend failed to start: ${error.message}`)));
|
|
52
|
+
child.on('close', (code, signal) => { if (this.child === child)
|
|
53
|
+
this.child = undefined; this.fail(new Error(`MCP backend exited (${code ?? 'null'}${signal ? `, ${signal}` : ''})`)); });
|
|
54
|
+
await this.request('initialize', { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'yoke-code-intelligence', version: '0.1.0' } }, timeoutMs);
|
|
55
|
+
await this.notify('notifications/initialized', {});
|
|
56
|
+
}
|
|
57
|
+
notify(method, params) {
|
|
58
|
+
const payload = JSON.stringify({ jsonrpc: '2.0', method, params });
|
|
59
|
+
this.write(payload);
|
|
60
|
+
return Promise.resolve();
|
|
61
|
+
}
|
|
62
|
+
request(method, params, timeoutMs) {
|
|
63
|
+
if (!this.child)
|
|
64
|
+
return Promise.reject(new Error('MCP process is not running'));
|
|
65
|
+
const id = this.nextId++;
|
|
66
|
+
this.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }));
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`MCP request timed out: ${method}`)); void this.close(); }, timeoutMs);
|
|
69
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
write(payload) {
|
|
73
|
+
if (!this.child?.stdin.writable)
|
|
74
|
+
throw new Error('MCP stdin is closed');
|
|
75
|
+
if (this.framing === 'line')
|
|
76
|
+
this.child.stdin.write(`${payload}\n`);
|
|
77
|
+
else
|
|
78
|
+
this.child.stdin.write(`Content-Length: ${Buffer.byteLength(payload, 'utf8')}\r\n\r\n${payload}`);
|
|
79
|
+
}
|
|
80
|
+
consume(chunk) {
|
|
81
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
82
|
+
if (this.buffer.byteLength > this.maxBytes) {
|
|
83
|
+
void this.close();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
while (this.buffer.byteLength > 0) {
|
|
87
|
+
if (this.framing === 'line') {
|
|
88
|
+
const newline = this.buffer.indexOf(10);
|
|
89
|
+
if (newline < 0)
|
|
90
|
+
return;
|
|
91
|
+
const line = this.buffer.subarray(0, newline).toString('utf8').trim();
|
|
92
|
+
this.buffer = this.buffer.subarray(newline + 1);
|
|
93
|
+
if (line)
|
|
94
|
+
this.accept(line);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const headerEnd = this.buffer.indexOf('\r\n\r\n');
|
|
98
|
+
if (headerEnd < 0)
|
|
99
|
+
return;
|
|
100
|
+
const header = this.buffer.subarray(0, headerEnd).toString('ascii');
|
|
101
|
+
const match = /Content-Length:\s*(\d+)/i.exec(header);
|
|
102
|
+
if (!match) {
|
|
103
|
+
this.buffer = this.buffer.subarray(headerEnd + 4);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const length = Number(match[1]);
|
|
107
|
+
const start = headerEnd + 4;
|
|
108
|
+
if (this.buffer.byteLength < start + length)
|
|
109
|
+
return;
|
|
110
|
+
const body = this.buffer.subarray(start, start + length).toString('utf8');
|
|
111
|
+
this.buffer = this.buffer.subarray(start + length);
|
|
112
|
+
this.accept(body);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
accept(raw) {
|
|
116
|
+
try {
|
|
117
|
+
const message = JSON.parse(raw);
|
|
118
|
+
if (typeof message.id !== 'number')
|
|
119
|
+
return;
|
|
120
|
+
const pending = this.pending.get(message.id);
|
|
121
|
+
if (!pending)
|
|
122
|
+
return;
|
|
123
|
+
this.pending.delete(message.id);
|
|
124
|
+
clearTimeout(pending.timer);
|
|
125
|
+
if (message.error)
|
|
126
|
+
pending.reject(new Error(message.error.message ?? 'MCP request failed'));
|
|
127
|
+
else
|
|
128
|
+
pending.resolve(message.result);
|
|
129
|
+
}
|
|
130
|
+
catch { /* backend log/noise on stdout is ignored; protocol errors surface by timeout */ }
|
|
131
|
+
}
|
|
132
|
+
fail(error) {
|
|
133
|
+
for (const pending of this.pending.values()) {
|
|
134
|
+
clearTimeout(pending.timer);
|
|
135
|
+
pending.reject(error);
|
|
136
|
+
}
|
|
137
|
+
this.pending.clear();
|
|
138
|
+
}
|
|
139
|
+
}
|