@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
package/dist/dashboard/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
|
-
import { lstatSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { parse } from 'yaml';
|
|
6
6
|
import { z } from 'zod';
|
|
@@ -8,8 +8,14 @@ import { listProjects } from './registry.js';
|
|
|
8
8
|
import { dashboardPage } from './page.js';
|
|
9
9
|
import { readEvents } from '../observability/events.js';
|
|
10
10
|
import { estimateSchedule } from '../estimation/schedule.js';
|
|
11
|
-
import { pauseProjectGoal } from '../goals/command.js';
|
|
12
|
-
import {
|
|
11
|
+
import { pauseProjectGoal, runProjectGoal } from '../goals/command.js';
|
|
12
|
+
import { runLoopCommand } from '../loop/run-command.js';
|
|
13
|
+
import { isPidAlive, readLock } from '../loop/lock.js';
|
|
14
|
+
import { appendEvent } from '../observability/events.js';
|
|
15
|
+
import { queueChange } from '../change/inbox.js';
|
|
16
|
+
import { statePath } from '../workspace/state.js';
|
|
17
|
+
import { parsePeriod, parseRanking, projectAnalytics, projectHistory, workspaceAnalytics } from './analytics.js';
|
|
18
|
+
import { DASHBOARD_LIMITS, DashboardChangePayloadSchema, DashboardControlPayloadSchema, DashboardNotePayloadSchema, DashboardResumePayloadSchema, parseDashboardLimit, } from './contracts.js';
|
|
13
19
|
const text = z.string().max(16000);
|
|
14
20
|
const number = z.number().finite().nonnegative();
|
|
15
21
|
const Goal = z.object({ objective: text, status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), reason: text.optional(), pendingAttempt: z.object({ provider: text, model: text.optional(), startedAt: text }).optional(), attempts: z.array(z.object({ durationMs: number.optional(), provider: text.optional(), success: z.boolean().optional(), summary: text.optional(), inputTokens: number.optional(), outputTokens: number.optional() })).max(200).default([]) });
|
|
@@ -17,8 +23,11 @@ const Status = z.object({ state: text, phase: text.optional(), reason: text.opti
|
|
|
17
23
|
const Stories = z.array(z.object({ id: text, title: text, passes: z.boolean(), priority: number.optional(), area: text.optional(), writes: z.array(text).optional(), needs: z.array(text).optional() })).max(2000);
|
|
18
24
|
const Check = z.object({ id: text, status: z.enum(['passed', 'failed', 'unverified']), generatedAt: text, summary: text, criteria: z.array(z.object({ id: text, text, status: z.enum(['passed', 'failed', 'unverified']), summary: text })).max(500) });
|
|
19
25
|
const Durations = z.array(z.object({ storyId: text, ms: number.positive() })).max(5000);
|
|
20
|
-
const MAX_FILE =
|
|
26
|
+
const MAX_FILE = DASHBOARD_LIMITS.fileBytes;
|
|
27
|
+
const activeResumes = new Set();
|
|
21
28
|
function safeFile(root, relative) {
|
|
29
|
+
if (!relative || relative.startsWith('/') || relative.includes('\\') || relative.split('/').some(part => !part || part === '.' || part === '..'))
|
|
30
|
+
throw new Error('Unsafe project path');
|
|
22
31
|
let file = root;
|
|
23
32
|
try {
|
|
24
33
|
for (const part of relative.split('/')) {
|
|
@@ -39,6 +48,62 @@ function safeFile(root, relative) {
|
|
|
39
48
|
throw error;
|
|
40
49
|
}
|
|
41
50
|
}
|
|
51
|
+
function requestBody(req, empty = { action: 'pause' }) {
|
|
52
|
+
const length = req.headers['content-length'];
|
|
53
|
+
if (typeof length === 'string' && Number.isFinite(Number(length)) && Number(length) > DASHBOARD_LIMITS.requestBytes) {
|
|
54
|
+
req.resume();
|
|
55
|
+
return Promise.reject(new Error('Dashboard request body too large'));
|
|
56
|
+
}
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const chunks = [];
|
|
59
|
+
let size = 0;
|
|
60
|
+
let rejected = false;
|
|
61
|
+
req.on('data', chunk => {
|
|
62
|
+
size += Buffer.byteLength(chunk);
|
|
63
|
+
if (size > DASHBOARD_LIMITS.requestBytes) {
|
|
64
|
+
rejected = true;
|
|
65
|
+
req.resume();
|
|
66
|
+
reject(new Error('Dashboard request body too large'));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
chunks.push(Buffer.from(chunk));
|
|
70
|
+
});
|
|
71
|
+
req.on('end', () => {
|
|
72
|
+
if (rejected)
|
|
73
|
+
return;
|
|
74
|
+
const body = Buffer.concat(chunks).toString('utf8').trim();
|
|
75
|
+
if (!body) {
|
|
76
|
+
resolve(empty);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
resolve(JSON.parse(body));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
reject(new Error('Invalid dashboard request body'));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
req.on('error', reject);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function writePauseSignal(root) {
|
|
90
|
+
const directory = statePath(root);
|
|
91
|
+
const path = statePath(root, 'loop.pause');
|
|
92
|
+
mkdirSync(directory, { recursive: true });
|
|
93
|
+
try {
|
|
94
|
+
writeFileSync(path, 'pause\n', { flag: 'wx', mode: 0o600 });
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error.code !== 'EEXIST')
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function projectIsRunning(root) {
|
|
102
|
+
if (activeResumes.has(root))
|
|
103
|
+
return true;
|
|
104
|
+
const lock = readLock(root);
|
|
105
|
+
return Boolean(lock && isPidAlive(lock.pid));
|
|
106
|
+
}
|
|
42
107
|
function snapshot(project, detail) {
|
|
43
108
|
const errors = project.error ? [project.error] : [];
|
|
44
109
|
const read = (file, schema, yaml = false) => {
|
|
@@ -75,13 +140,13 @@ function snapshot(project, detail) {
|
|
|
75
140
|
errors.push(error.message);
|
|
76
141
|
}
|
|
77
142
|
}
|
|
78
|
-
return { ...project, goal, status, errors, ...(detail ? { stories, check, events: project.error ? [] : readEvents(project.root,
|
|
143
|
+
return { ...project, goal, status, errors, ...(detail ? { stories, check, events: project.error ? [] : readEvents(project.root, DASHBOARD_LIMITS.events), estimate: estimateSchedule(stories, Math.max(1, status?.parallel?.maxConcurrency ?? 1), history) } : {}) };
|
|
79
144
|
}
|
|
80
145
|
export async function startDashboard(options = {}) {
|
|
81
146
|
const token = randomBytes(32).toString('hex');
|
|
82
147
|
const nonce = randomBytes(24).toString('base64');
|
|
83
148
|
let origin = '';
|
|
84
|
-
const server = createServer((req, res) => {
|
|
149
|
+
const server = createServer(async (req, res) => {
|
|
85
150
|
res.setHeader('Cache-Control', 'no-store');
|
|
86
151
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
87
152
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
@@ -91,14 +156,19 @@ export async function startDashboard(options = {}) {
|
|
|
91
156
|
send(403, { error: 'Untrusted request origin' });
|
|
92
157
|
return;
|
|
93
158
|
}
|
|
94
|
-
let
|
|
159
|
+
let requested;
|
|
95
160
|
try {
|
|
96
|
-
|
|
161
|
+
requested = new URL(req.url ?? '/', origin);
|
|
97
162
|
}
|
|
98
163
|
catch {
|
|
99
164
|
send(400, { error: 'Invalid URL' });
|
|
100
165
|
return;
|
|
101
166
|
}
|
|
167
|
+
if (requested.origin !== origin) {
|
|
168
|
+
send(403, { error: 'Untrusted request origin' });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const path = requested.pathname;
|
|
102
172
|
if (req.method === 'POST') {
|
|
103
173
|
const supplied = req.headers['x-yoke-token'];
|
|
104
174
|
if (req.headers.origin !== origin || typeof supplied !== 'string' || Buffer.byteLength(supplied) !== token.length || !timingSafeEqual(Buffer.from(supplied), Buffer.from(token))) {
|
|
@@ -121,7 +191,20 @@ export async function startDashboard(options = {}) {
|
|
|
121
191
|
send(200, listProjects().map(project => snapshot(project, false)));
|
|
122
192
|
return;
|
|
123
193
|
}
|
|
124
|
-
|
|
194
|
+
if (req.method === 'GET' && path === '/api/workspace/analytics') {
|
|
195
|
+
let period, sort;
|
|
196
|
+
try {
|
|
197
|
+
period = parsePeriod(requested.searchParams);
|
|
198
|
+
sort = parseRanking(requested.searchParams);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
send(400, { error: error.message });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
send(200, workspaceAnalytics(listProjects(), period, sort));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const match = /^\/api\/projects\/([a-f0-9]{32})(\/pause|\/resume|\/notes|\/changes|\/analytics|\/history|\/events)?$/u.exec(path);
|
|
125
208
|
if (match) {
|
|
126
209
|
const project = listProjects().find(project => project.id === match[1]);
|
|
127
210
|
if (!project) {
|
|
@@ -135,7 +218,7 @@ export async function startDashboard(options = {}) {
|
|
|
135
218
|
}
|
|
136
219
|
let period;
|
|
137
220
|
try {
|
|
138
|
-
period = parsePeriod(
|
|
221
|
+
period = parsePeriod(requested.searchParams);
|
|
139
222
|
}
|
|
140
223
|
catch (error) {
|
|
141
224
|
send(400, { error: error.message });
|
|
@@ -144,18 +227,110 @@ export async function startDashboard(options = {}) {
|
|
|
144
227
|
send(200, projectAnalytics(project.root, period));
|
|
145
228
|
return;
|
|
146
229
|
}
|
|
230
|
+
if (req.method === 'GET' && (match[2] === '/history' || match[2] === '/events')) {
|
|
231
|
+
if (project.error) {
|
|
232
|
+
send(409, { error: project.error });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
let period, limit;
|
|
236
|
+
try {
|
|
237
|
+
period = parsePeriod(requested.searchParams);
|
|
238
|
+
limit = parseDashboardLimit(requested.searchParams.get('limit'));
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
send(400, { error: error.message });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
send(200, projectHistory(project.root, period, limit));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
147
247
|
if (req.method === 'GET' && !match[2]) {
|
|
148
248
|
send(200, snapshot(project, true));
|
|
149
249
|
return;
|
|
150
250
|
}
|
|
151
251
|
if (req.method === 'POST' && match[2] === '/pause') {
|
|
152
|
-
|
|
153
|
-
|
|
252
|
+
try {
|
|
253
|
+
DashboardControlPayloadSchema.parse(await requestBody(req, { action: 'pause' }));
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
send(error.message === 'Dashboard request body too large' ? 413 : 400, { error: error.message === 'Dashboard request body too large' ? error.message : 'Invalid dashboard control payload' });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const current = snapshot(project, false);
|
|
260
|
+
if (project.error) {
|
|
261
|
+
send(409, { error: project.error });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (current.goal)
|
|
265
|
+
pauseProjectGoal(project.root);
|
|
266
|
+
writePauseSignal(project.root);
|
|
267
|
+
const response = { status: 'pause-requested' };
|
|
268
|
+
send(200, response);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (req.method === 'POST' && match[2] === '/resume') {
|
|
272
|
+
try {
|
|
273
|
+
DashboardResumePayloadSchema.parse(await requestBody(req, { action: 'resume' }));
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
send(error.message === 'Dashboard request body too large' ? 413 : 400, { error: error.message === 'Dashboard request body too large' ? error.message : 'Invalid dashboard resume payload' });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const current = snapshot(project, false);
|
|
280
|
+
if (project.error) {
|
|
281
|
+
send(409, { error: project.error });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (projectIsRunning(project.root)) {
|
|
285
|
+
send(409, { status: 'already-running', error: 'Project is already running' });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const goal = current.goal !== null && current.goal.status !== 'complete';
|
|
289
|
+
activeResumes.add(project.root);
|
|
290
|
+
void Promise.resolve().then(async () => {
|
|
291
|
+
if (goal)
|
|
292
|
+
await runProjectGoal(project.root);
|
|
293
|
+
else
|
|
294
|
+
await runLoopCommand(project.root, {});
|
|
295
|
+
}).catch(() => undefined).finally(() => { activeResumes.delete(project.root); });
|
|
296
|
+
const response = { status: 'resume-requested' };
|
|
297
|
+
send(202, response);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (req.method === 'POST' && match[2] === '/notes') {
|
|
301
|
+
let payload;
|
|
302
|
+
try {
|
|
303
|
+
payload = DashboardNotePayloadSchema.parse(await requestBody(req, {}));
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
send(error.message === 'Dashboard request body too large' ? 413 : 400, { error: error.message === 'Dashboard request body too large' ? error.message : 'Invalid dashboard note payload' });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (project.error) {
|
|
310
|
+
send(409, { error: project.error });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
appendEvent(project.root, { runId: 'dashboard', timestamp: new Date().toISOString(), type: 'operator-note', data: { note: payload.note } });
|
|
314
|
+
const response = { status: 'note-added' };
|
|
315
|
+
send(200, response);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (req.method === 'POST' && match[2] === '/changes') {
|
|
319
|
+
let payload;
|
|
320
|
+
try {
|
|
321
|
+
payload = DashboardChangePayloadSchema.parse(await requestBody(req, {}));
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
send(error.message === 'Dashboard request body too large' ? 413 : 400, { error: error.message === 'Dashboard request body too large' ? error.message : 'Invalid dashboard change payload' });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (project.error) {
|
|
328
|
+
send(409, { error: project.error });
|
|
154
329
|
return;
|
|
155
330
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
send(200,
|
|
331
|
+
const change = queueChange(project.root, payload.request);
|
|
332
|
+
const response = { status: 'pending', requestId: change.id };
|
|
333
|
+
send(200, response);
|
|
159
334
|
return;
|
|
160
335
|
}
|
|
161
336
|
}
|
|
@@ -55,7 +55,7 @@ export function readEvents(root, limit = 200) {
|
|
|
55
55
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > EVENT_MAX_BYTES)
|
|
56
56
|
return [];
|
|
57
57
|
const value = JSON.parse(readFileSync(file, 'utf8'));
|
|
58
|
-
if (value?.schemaVersion !== 1 || typeof value.id !== 'string' || typeof value.runId !== 'string' || typeof value.timestamp !== 'string' || !Number.isFinite(Date.parse(value.timestamp)) || !['status', 'tokens', 'phase-ended', 'attempt-ended', 'accepted'].includes(value.type))
|
|
58
|
+
if (value?.schemaVersion !== 1 || typeof value.id !== 'string' || typeof value.runId !== 'string' || typeof value.timestamp !== 'string' || !Number.isFinite(Date.parse(value.timestamp)) || !['status', 'tokens', 'phase-ended', 'attempt-ended', 'accepted', 'operator-note'].includes(value.type))
|
|
59
59
|
return [];
|
|
60
60
|
if (value.durationMs !== undefined && (!Number.isFinite(value.durationMs) || value.durationMs < 0))
|
|
61
61
|
return [];
|
|
@@ -30,7 +30,7 @@ export function archiveMeasurement(root, event) {
|
|
|
30
30
|
throw error;
|
|
31
31
|
}
|
|
32
32
|
const data = event.data ?? {};
|
|
33
|
-
const allowed = ['inputTokens', 'outputTokens', 'cachedInputTokens', 'cacheWriteInputTokens', 'reasoningOutputTokens', 'totalCostUsd', '
|
|
33
|
+
const allowed = ['inputTokens', 'outputTokens', 'cachedInputTokens', 'cacheWriteInputTokens', 'reasoningOutputTokens', 'totalCostUsd', 'agent', 'provider', 'model', 'actualModel', 'requestedModel', 'variant', 'role', 'calls', 'measurementComplete', 'costMeasurementComplete', 'usageAvailable', 'prediction', 'errorMs', 'withinObservedRange', 'escalated'];
|
|
34
34
|
const compact = { ...event, data: Object.fromEntries(allowed.filter(key => data[key] !== undefined).map(key => [key, data[key]])) };
|
|
35
35
|
appendFileSync(file, JSON.stringify(compact) + '\n');
|
|
36
36
|
}
|
package/dist/retrofit/command.js
CHANGED
|
@@ -17,7 +17,8 @@ export function runRetrofit(targetDir, opts) {
|
|
|
17
17
|
: (detection.agents.length > 0 ? detection.agents : [opts.host ?? detectHostAgent() ?? 'claude']);
|
|
18
18
|
const existing = loadConfig(targetDir);
|
|
19
19
|
const codeGraph = opts.codeGraph ?? existing?.codeGraph ?? 'graphify';
|
|
20
|
-
const
|
|
20
|
+
const codeIntelligence = opts.codeIntelligence ?? existing?.codeIntelligence?.mode ?? 'off';
|
|
21
|
+
const actions = planRetrofit(canonDir, targetDir, agents, codeGraph, codeIntelligence);
|
|
21
22
|
const backupDir = join(targetDir, '.yoke', 'backup', String(Date.now()));
|
|
22
23
|
const applied = applyActions(actions, targetDir, { backupDir });
|
|
23
24
|
// Ensure runtime artifacts (loop status/log, worktrees, backups) are gitignored
|
|
@@ -31,6 +32,7 @@ export function runRetrofit(targetDir, opts) {
|
|
|
31
32
|
agents: mergedAgents,
|
|
32
33
|
loop: { ...existing?.loop, enabled: opts.loop },
|
|
33
34
|
codeGraph,
|
|
35
|
+
codeIntelligence: { ...(existing?.codeIntelligence ?? {}), mode: codeIntelligence },
|
|
34
36
|
...(existing?.design
|
|
35
37
|
? { design: existing.design }
|
|
36
38
|
: detection.ui.detected ? { design: { mode: 'auto', max: 4 } } : {}),
|
package/dist/retrofit/config.js
CHANGED
|
@@ -6,7 +6,16 @@ import { AgentSchema, PermissionProfileSchema } from '../agents/contracts.js';
|
|
|
6
6
|
import { ProjectQualityDefaultsSchema } from '../quality/types.js';
|
|
7
7
|
import { DEFAULT_OUTPUT_POLICY } from '../output/types.js';
|
|
8
8
|
import { ToolActionSchema } from '../execution/actions.js';
|
|
9
|
+
import { CodeIntelligenceModeSchema } from '../code-intelligence/contracts.js';
|
|
10
|
+
export { CodeIntelligenceModeSchema };
|
|
9
11
|
const CodeGraphSchema = z.enum(['graphify', 'serena']);
|
|
12
|
+
const CodeIntelligenceProcessSchema = z.object({ command: z.string().min(1), args: z.array(z.string()).max(32).optional() });
|
|
13
|
+
const CodeIntelligenceSchema = z.object({
|
|
14
|
+
mode: CodeIntelligenceModeSchema.default('off'), workspaceId: z.string().min(1).optional(),
|
|
15
|
+
graft: CodeIntelligenceProcessSchema.optional(), graphify: CodeIntelligenceProcessSchema.optional(), serena: CodeIntelligenceProcessSchema.optional(),
|
|
16
|
+
policy: z.object({ allowNetwork: z.boolean().default(false), excludePatterns: z.array(z.string().min(1)).max(100).default([]), maxFileBytes: z.number().int().positive().max(20_000_000).optional() }).optional(),
|
|
17
|
+
limits: z.object({ tokenBudget: z.number().int().min(128).max(16000).default(2400), timeoutMs: z.number().int().min(100).max(600000).default(10000), maxBytes: z.number().int().positive().max(10_000_000).default(2_000_000), maxBackends: z.number().int().min(1).max(3).default(3) }).optional(),
|
|
18
|
+
});
|
|
10
19
|
const SmokeFlowSchema = z.object({ name: z.string().min(1), path: z.string().min(1), landmark: z.string().optional() });
|
|
11
20
|
const SmokeSchema = z.object({ baseUrl: z.string().min(1), flows: z.array(SmokeFlowSchema).min(1) });
|
|
12
21
|
const OutputPolicySchema = z.object({
|
|
@@ -118,6 +127,7 @@ export const YokeConfigSchema = z.object({
|
|
|
118
127
|
max: z.number().int().positive().default(4),
|
|
119
128
|
}).optional(),
|
|
120
129
|
codeGraph: CodeGraphSchema.optional(),
|
|
130
|
+
codeIntelligence: CodeIntelligenceSchema.optional(),
|
|
121
131
|
smoke: SmokeSchema.optional(),
|
|
122
132
|
quality: ProjectQualityDefaultsSchema.optional(),
|
|
123
133
|
output: OutputPolicySchema.optional(),
|
package/dist/retrofit/plan.js
CHANGED
|
@@ -10,7 +10,7 @@ export function planClaudeRetrofit(canonDir, targetDir) {
|
|
|
10
10
|
return planClaude(canonDir, targetDir);
|
|
11
11
|
}
|
|
12
12
|
export const PLANNERS = {
|
|
13
|
-
claude: (c, t, cg) => planClaude(c, t, undefined, cg),
|
|
13
|
+
claude: (c, t, cg, ci) => planClaude(c, t, undefined, cg, undefined, ci),
|
|
14
14
|
codex: planCodex,
|
|
15
15
|
gemini: planGemini,
|
|
16
16
|
qwen: planQwen,
|
|
@@ -18,7 +18,7 @@ export const PLANNERS = {
|
|
|
18
18
|
kilo: planKilo,
|
|
19
19
|
pi: planPi,
|
|
20
20
|
};
|
|
21
|
-
export function planRetrofit(canonDir, targetDir, agents, codeGraph = 'graphify') {
|
|
21
|
+
export function planRetrofit(canonDir, targetDir, agents, codeGraph = 'graphify', codeIntelligence = 'off') {
|
|
22
22
|
const seen = new Set();
|
|
23
23
|
const merged = [];
|
|
24
24
|
for (const action of baseContextActions(canonDir)) {
|
|
@@ -26,7 +26,7 @@ export function planRetrofit(canonDir, targetDir, agents, codeGraph = 'graphify'
|
|
|
26
26
|
merged.push(action);
|
|
27
27
|
}
|
|
28
28
|
for (const agent of agents) {
|
|
29
|
-
for (const action of PLANNERS[agent](canonDir, targetDir, codeGraph)) {
|
|
29
|
+
for (const action of PLANNERS[agent](canonDir, targetDir, codeGraph, codeIntelligence)) {
|
|
30
30
|
if (seen.has(action.target))
|
|
31
31
|
continue;
|
|
32
32
|
seen.add(action.target);
|
|
@@ -22,7 +22,7 @@ This project uses the Yoke harness. Baseline instructions:
|
|
|
22
22
|
${rtkNote ? `\n${rtkNote}\n` : ''}${composeNote ? `\n${composeNote}\n` : ''}
|
|
23
23
|
${PRESERVE_SCAFFOLD}
|
|
24
24
|
`;
|
|
25
|
-
export function planClaude(canonDir, targetDir, wslAvailable = hasWsl(), codeGraph = 'graphify', gstackDetected = detectGstack(targetDir)) {
|
|
25
|
+
export function planClaude(canonDir, targetDir, wslAvailable = hasWsl(), codeGraph = 'graphify', gstackDetected = detectGstack(targetDir), codeIntelligence = 'off') {
|
|
26
26
|
const manifest = loadManifest(join(canonDir, 'manifest.yaml'));
|
|
27
27
|
const actions = [];
|
|
28
28
|
for (const skill of manifest.skills) {
|
|
@@ -45,8 +45,8 @@ export function planClaude(canonDir, targetDir, wslAvailable = hasWsl(), codeGra
|
|
|
45
45
|
actions.push({
|
|
46
46
|
kind: 'write',
|
|
47
47
|
target: '.mcp.json',
|
|
48
|
-
content: JSON.stringify({ mcpServers: mcpServers(codeGraph) }, null, 2) + '\n',
|
|
49
|
-
reason: 'MCP servers (code-graph + playwright)',
|
|
48
|
+
content: JSON.stringify({ mcpServers: mcpServers(codeGraph, codeIntelligence, targetDir) }, null, 2) + '\n',
|
|
49
|
+
reason: codeIntelligence === 'off' ? 'MCP servers (code-graph + playwright)' : 'Yoke code-intelligence facade + playwright',
|
|
50
50
|
});
|
|
51
51
|
if (rtkHookable) {
|
|
52
52
|
actions.push({
|
|
@@ -3,8 +3,8 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { loadManifest } from '../../canon/manifest.js';
|
|
4
4
|
import { mcpServers, rtkInstruction } from '../tools.js';
|
|
5
5
|
import { skillPackageActions } from '../skill-actions.js';
|
|
6
|
-
function tomlMcp(codeGraph) {
|
|
7
|
-
const servers = mcpServers(codeGraph);
|
|
6
|
+
function tomlMcp(codeGraph, codeIntelligence, targetDir) {
|
|
7
|
+
const servers = mcpServers(codeGraph, codeIntelligence, targetDir);
|
|
8
8
|
// Codex reads MCP servers from ~/.codex/config.toml. This project-level file is a
|
|
9
9
|
// ready-to-merge snippet; users append these blocks to their global config.
|
|
10
10
|
return Object.entries(servers)
|
|
@@ -14,7 +14,7 @@ function tomlMcp(codeGraph) {
|
|
|
14
14
|
})
|
|
15
15
|
.join('\n');
|
|
16
16
|
}
|
|
17
|
-
export function planCodex(canonDir,
|
|
17
|
+
export function planCodex(canonDir, targetDir, codeGraph = 'graphify', codeIntelligence = 'off') {
|
|
18
18
|
const manifest = loadManifest(join(canonDir, 'manifest.yaml'));
|
|
19
19
|
const baseline = readFileSync(join(canonDir, 'AGENTS.md'), 'utf8');
|
|
20
20
|
const actions = manifest.skills.flatMap(skill => skillPackageActions(canonDir, skill, 'codex'));
|
|
@@ -32,8 +32,8 @@ export function planCodex(canonDir, _targetDir, codeGraph = 'graphify') {
|
|
|
32
32
|
}, {
|
|
33
33
|
kind: 'write',
|
|
34
34
|
target: '.codex/config.toml',
|
|
35
|
-
content: `# Yoke project configuration. Codex loads this in trusted repositories.\n\n[features]\nhooks = true\n\n${tomlMcp(codeGraph)}`,
|
|
36
|
-
reason: 'MCP servers (code-graph + playwright)',
|
|
35
|
+
content: `# Yoke project configuration. Codex loads this in trusted repositories.\n\n[features]\nhooks = true\n\n${tomlMcp(codeGraph, codeIntelligence, targetDir)}`,
|
|
36
|
+
reason: codeIntelligence === 'off' ? 'MCP servers (code-graph + playwright)' : 'Yoke code-intelligence facade + playwright',
|
|
37
37
|
}, {
|
|
38
38
|
kind: 'write',
|
|
39
39
|
target: '.codex/hooks.json',
|
|
@@ -8,7 +8,7 @@ import { skillPackageActions } from '../skill-actions.js';
|
|
|
8
8
|
function tomlString(s) {
|
|
9
9
|
return '"""\n' + s.replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"') + '\n"""';
|
|
10
10
|
}
|
|
11
|
-
export function planGemini(canonDir,
|
|
11
|
+
export function planGemini(canonDir, targetDir, codeGraph = 'graphify', codeIntelligence = 'off') {
|
|
12
12
|
const manifest = loadManifest(join(canonDir, 'manifest.yaml'));
|
|
13
13
|
const actions = [];
|
|
14
14
|
// GEMINI.md: baseline + instruction for commands outside the rewrite hook.
|
|
@@ -56,7 +56,7 @@ export function planGemini(canonDir, _targetDir, codeGraph = 'graphify') {
|
|
|
56
56
|
target: '.gemini/settings.json',
|
|
57
57
|
merge: true,
|
|
58
58
|
content: JSON.stringify({
|
|
59
|
-
mcpServers: mcpServers(codeGraph),
|
|
59
|
+
mcpServers: mcpServers(codeGraph, codeIntelligence, targetDir),
|
|
60
60
|
context: { fileName: ['AGENTS.md', 'GEMINI.md'] },
|
|
61
61
|
hooks: { BeforeTool: [{ matcher: '^run_shell_command$', hooks: [{ name: 'yoke-rtk', type: 'command', command: 'node .gemini/hooks/gemini-rtk-hook.mjs' }] }] },
|
|
62
62
|
}, null, 2) + '\n',
|
|
@@ -15,7 +15,7 @@ permission:
|
|
|
15
15
|
|
|
16
16
|
Review the observed diff and test evidence. Do not modify files. Return only actionable findings grounded in evidence.
|
|
17
17
|
`;
|
|
18
|
-
export function planKilo(canonDir,
|
|
18
|
+
export function planKilo(canonDir, targetDir, codeGraph = 'graphify', codeIntelligence = 'off') {
|
|
19
19
|
const manifest = loadManifest(join(canonDir, 'manifest.yaml'));
|
|
20
20
|
const baseline = readFileSync(join(canonDir, 'AGENTS.md'), 'utf8');
|
|
21
21
|
const actions = manifest.skills.flatMap(skill => skillPackageActions(canonDir, skill, 'kilo'));
|
|
@@ -31,7 +31,7 @@ export function planKilo(canonDir, _targetDir, codeGraph = 'graphify') {
|
|
|
31
31
|
content: JSON.stringify({
|
|
32
32
|
$schema: 'https://app.kilo.ai/config.json',
|
|
33
33
|
instructions: ['AGENTS.md', '.yoke/context/*.md'],
|
|
34
|
-
mcp: openCodeMcpServers(codeGraph),
|
|
34
|
+
mcp: openCodeMcpServers(codeGraph, codeIntelligence, targetDir),
|
|
35
35
|
}, null, 2) + '\n',
|
|
36
36
|
reason: 'Kilo instructions + MCP servers',
|
|
37
37
|
}, {
|
|
@@ -15,7 +15,7 @@ tools:
|
|
|
15
15
|
|
|
16
16
|
Review the observed diff and test evidence. Do not modify files. Return only actionable findings grounded in evidence.
|
|
17
17
|
`;
|
|
18
|
-
export function planOpenCode(canonDir,
|
|
18
|
+
export function planOpenCode(canonDir, targetDir, codeGraph = 'graphify', codeIntelligence = 'off') {
|
|
19
19
|
const manifest = loadManifest(join(canonDir, 'manifest.yaml'));
|
|
20
20
|
const baseline = readFileSync(join(canonDir, 'AGENTS.md'), 'utf8');
|
|
21
21
|
const actions = manifest.skills.flatMap(skill => skillPackageActions(canonDir, skill, 'opencode'));
|
|
@@ -31,7 +31,7 @@ export function planOpenCode(canonDir, _targetDir, codeGraph = 'graphify') {
|
|
|
31
31
|
content: JSON.stringify({
|
|
32
32
|
$schema: 'https://opencode.ai/config.json',
|
|
33
33
|
instructions: ['AGENTS.md', '.yoke/context/*.md'],
|
|
34
|
-
mcp: openCodeMcpServers(codeGraph),
|
|
34
|
+
mcp: openCodeMcpServers(codeGraph, codeIntelligence, targetDir),
|
|
35
35
|
}, null, 2) + '\n',
|
|
36
36
|
reason: 'OpenCode instructions + MCP servers',
|
|
37
37
|
}, {
|
|
@@ -4,7 +4,7 @@ import { loadManifest } from '../../canon/manifest.js';
|
|
|
4
4
|
import { rtkInstruction } from '../tools.js';
|
|
5
5
|
import { PRESERVE_SCAFFOLD } from '../preserve.js';
|
|
6
6
|
import { skillPackageActions } from '../skill-actions.js';
|
|
7
|
-
export function planPi(canonDir, _targetDir, _codeGraph = 'graphify') {
|
|
7
|
+
export function planPi(canonDir, _targetDir, _codeGraph = 'graphify', _codeIntelligence = 'off') {
|
|
8
8
|
const manifest = loadManifest(join(canonDir, 'manifest.yaml'));
|
|
9
9
|
const baseline = readFileSync(join(canonDir, 'AGENTS.md'), 'utf8');
|
|
10
10
|
const actions = manifest.skills.flatMap(skill => skillPackageActions(canonDir, skill, 'pi'));
|
|
@@ -8,7 +8,7 @@ import { skillPackageActions } from '../skill-actions.js';
|
|
|
8
8
|
function tomlString(s) {
|
|
9
9
|
return '"""\n' + s.replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"') + '\n"""';
|
|
10
10
|
}
|
|
11
|
-
export function planQwen(canonDir,
|
|
11
|
+
export function planQwen(canonDir, targetDir, codeGraph = 'graphify', codeIntelligence = 'off') {
|
|
12
12
|
const manifest = loadManifest(join(canonDir, 'manifest.yaml'));
|
|
13
13
|
const actions = [];
|
|
14
14
|
// QWEN.md: baseline + instruction for commands outside the rewrite hook.
|
|
@@ -56,7 +56,7 @@ export function planQwen(canonDir, _targetDir, codeGraph = 'graphify') {
|
|
|
56
56
|
target: '.qwen/settings.json',
|
|
57
57
|
merge: true,
|
|
58
58
|
content: JSON.stringify({
|
|
59
|
-
mcpServers: mcpServers(codeGraph),
|
|
59
|
+
mcpServers: mcpServers(codeGraph, codeIntelligence, targetDir),
|
|
60
60
|
context: { fileName: ['AGENTS.md', 'QWEN.md'] },
|
|
61
61
|
hooks: { PreToolUse: [{ matcher: '^run_shell_command$', hooks: [{ name: 'yoke-rtk', type: 'command', command: 'node .qwen/hooks/qwen-rtk-hook.mjs' }] }] },
|
|
62
62
|
}, null, 2) + '\n',
|
package/dist/retrofit/tools.js
CHANGED
|
@@ -8,15 +8,17 @@ const CODE_GRAPH_SERVERS = {
|
|
|
8
8
|
args: ['start-mcp-server', '--open-web-dashboard', 'false'],
|
|
9
9
|
},
|
|
10
10
|
};
|
|
11
|
-
export function mcpServers(codeGraph = 'graphify') {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
export function mcpServers(codeGraph = 'graphify', codeIntelligence = 'off', targetDir = '.') {
|
|
12
|
+
if (codeIntelligence !== 'off')
|
|
13
|
+
return {
|
|
14
|
+
'code-intelligence': { command: 'yoke', args: ['code-intelligence-server', `--workspace=${targetDir}`, `--mode=${codeIntelligence}`] },
|
|
15
|
+
playwright: { command: 'npx', args: ['@playwright/mcp@latest'] },
|
|
16
|
+
};
|
|
17
|
+
return { [codeGraph]: CODE_GRAPH_SERVERS[codeGraph], playwright: { command: 'npx', args: ['@playwright/mcp@latest'] } };
|
|
16
18
|
}
|
|
17
19
|
/** OpenCode-family CLIs use an object with a local command array, not Claude's mcpServers shape. */
|
|
18
|
-
export function openCodeMcpServers(codeGraph = 'graphify') {
|
|
19
|
-
return Object.fromEntries(Object.entries(mcpServers(codeGraph)).map(([name, server]) => [name, {
|
|
20
|
+
export function openCodeMcpServers(codeGraph = 'graphify', codeIntelligence = 'off', targetDir = '.') {
|
|
21
|
+
return Object.fromEntries(Object.entries(mcpServers(codeGraph, codeIntelligence, targetDir)).map(([name, server]) => [name, {
|
|
20
22
|
type: 'local',
|
|
21
23
|
command: [server.command, ...server.args],
|
|
22
24
|
enabled: true,
|
package/dist/setup/command.js
CHANGED
|
@@ -75,6 +75,7 @@ export async function runSetup(targetDir, opts = {}) {
|
|
|
75
75
|
? detected.agents
|
|
76
76
|
: modelProviders.length ? ['qwen'] : [host ?? 'claude'];
|
|
77
77
|
const defaultGraph = opts.codeGraph ?? existing?.codeGraph ?? 'graphify';
|
|
78
|
+
const defaultCodeIntelligence = opts.codeIntelligence ?? existing?.codeIntelligence?.mode ?? 'off';
|
|
78
79
|
const defaultLoop = opts.loop ?? existing?.loop.enabled ?? true;
|
|
79
80
|
const defaultRunner = opts.runner ?? existing?.runner?.agent ?? (host && defaultAgents.includes(host) ? host : defaultAgents[0] ?? host ?? 'claude');
|
|
80
81
|
const defaultPolicy = opts.decisionPolicy ?? existing?.loop.decisionPolicy ?? (existing?.loop.onAmbiguity === 'abort' ? 'critical' : 'auto');
|
|
@@ -90,6 +91,7 @@ export async function runSetup(targetDir, opts = {}) {
|
|
|
90
91
|
try {
|
|
91
92
|
let agents = defaultAgents;
|
|
92
93
|
let codeGraph = defaultGraph;
|
|
94
|
+
let codeIntelligence = defaultCodeIntelligence;
|
|
93
95
|
let loop = defaultLoop;
|
|
94
96
|
let runner = defaultRunner;
|
|
95
97
|
let decisionPolicy = defaultPolicy;
|
|
@@ -112,7 +114,7 @@ export async function runSetup(targetDir, opts = {}) {
|
|
|
112
114
|
agents = [...agents, 'qwen'];
|
|
113
115
|
if (!agents.includes(runner))
|
|
114
116
|
agents = [...agents, runner];
|
|
115
|
-
const code = runRetrofit(targetDir, { loop, agents, codeGraph, host });
|
|
117
|
+
const code = runRetrofit(targetDir, { loop, agents, codeGraph, codeIntelligence, host });
|
|
116
118
|
if (code !== 0)
|
|
117
119
|
return code;
|
|
118
120
|
applyActions(presetActions, targetDir, { backupDir: join(targetDir, '.yoke', 'backups', `model-presets-${Date.now()}`) });
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Yoke Code Intelligence
|
|
2
|
+
|
|
3
|
+
Yoke integrates Graphify, Graft and Serena as a federation. Their repositories are not merged into Yoke and their individual MCP tools are not published to agents. Yoke publishes one snapshot-bound facade with six tools:
|
|
4
|
+
|
|
5
|
+
`code_context`, `code_symbol`, `code_trace`, `code_impact`, `code_edit_preview`, and `code_edit_apply`.
|
|
6
|
+
|
|
7
|
+
## Enable it
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
yoke setup . --yes --code-intelligence=active
|
|
11
|
+
# or, for an existing project:
|
|
12
|
+
yoke retrofit . --code-intelligence=active
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The default is `off`, which preserves the previous `codeGraph` behavior. `shadow` enables a read-only canary and keeps all edits blocked. `active` enables read operations and snapshot-bound edit previews. The generated host configuration exposes only Yoke's facade, so an agent cannot accidentally combine duplicate backend tools.
|
|
16
|
+
|
|
17
|
+
## Backend roles and tested pins
|
|
18
|
+
|
|
19
|
+
| Backend | Role | Launch command | Pin checked during integration |
|
|
20
|
+
|---|---|---|---|
|
|
21
|
+
| [Graft](https://github.com/trailhq/Graft) | structural code search, repo map, call graph | `graft mcp` | `05760b07abc0e427f5af8ad378889ee402c5afc6` / 0.17.0 |
|
|
22
|
+
| [Graphify](https://github.com/Graphify-Labs/graphify) | architecture/document knowledge graph | `python -m graphify.serve` | `67f99bd0059dd1bac9e44382907ef9f10098b39f` / 0.9.56 |
|
|
23
|
+
| [Serena](https://github.com/oraios/serena) | LSP symbols, references, implementations and semantic edits | `serena start-mcp-server --project-from-cwd --context codex` | `701e7c843f46c6a649203a488cece1bf19f1df90` / 1.7.1-dev |
|
|
24
|
+
|
|
25
|
+
Install and pin these tools in the project or developer environment before enabling the facade. Yoke never installs `latest` during a task. Missing tools produce an explicit `partial` response with backend warnings and provenance; they are never interpreted as “no references”.
|
|
26
|
+
|
|
27
|
+
## Safety contract
|
|
28
|
+
|
|
29
|
+
- Every request is tied to a workspace and a content-addressed snapshot of committed and uncommitted, non-sensitive files.
|
|
30
|
+
- Paths are workspace-relative; traversal, symlinks, `.env`/key material and policy-excluded paths are rejected.
|
|
31
|
+
- Read calls are bounded by timeout and output budgets. Results carry backend, version, source path, content hash, resolution and freshness.
|
|
32
|
+
- Edits run in a disposable Yoke sandbox. Preview writes a durable plan and diff but requires an approval record created by the Yoke control layer.
|
|
33
|
+
- Apply rechecks the snapshot, plan expiry, plan hash, an exclusive transaction lock and idempotency. It produces a managed worktree and does not commit or merge; the existing Yoke loop and gates remain authoritative for integration.
|
|
34
|
+
- Network access is not needed by the facade. Backend installation and any backend-specific network behavior remain an explicit environment concern.
|
|
35
|
+
|
|
36
|
+
For a host that has no native MCP support (currently Pi), use the normal Yoke loop and bounded CLI/skill adapter. Yoke does not invent a second MCP transport for that host.
|