@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +16 -12
  3. package/canon/manifest.yaml +1 -1
  4. package/dist/cli.js +16 -2
  5. package/dist/code-intelligence/adapters/graft.js +11 -0
  6. package/dist/code-intelligence/adapters/graphify.js +8 -0
  7. package/dist/code-intelligence/adapters/index.js +5 -0
  8. package/dist/code-intelligence/adapters/mcp.js +37 -0
  9. package/dist/code-intelligence/adapters/serena.js +11 -0
  10. package/dist/code-intelligence/adapters/types.js +1 -0
  11. package/dist/code-intelligence/contracts.js +137 -0
  12. package/dist/code-intelligence/coordinator.js +370 -0
  13. package/dist/code-intelligence/edit-plans.js +53 -0
  14. package/dist/code-intelligence/evidence.js +77 -0
  15. package/dist/code-intelligence/index.js +5 -0
  16. package/dist/code-intelligence/internal-types.js +1 -0
  17. package/dist/code-intelligence/mcp-client.js +139 -0
  18. package/dist/code-intelligence/mcp-server.js +93 -0
  19. package/dist/code-intelligence/snapshots.js +117 -0
  20. package/dist/code-intelligence/transactions.js +60 -0
  21. package/dist/dashboard/analytics.js +193 -29
  22. package/dist/dashboard/contracts.js +23 -0
  23. package/dist/dashboard/page.js +39 -94
  24. package/dist/dashboard/panels.js +85 -31
  25. package/dist/dashboard/server.js +190 -15
  26. package/dist/observability/events.js +1 -1
  27. package/dist/observability/history.js +1 -1
  28. package/dist/retrofit/command.js +3 -1
  29. package/dist/retrofit/config.js +10 -0
  30. package/dist/retrofit/gitignore.js +1 -0
  31. package/dist/retrofit/plan.js +3 -3
  32. package/dist/retrofit/planners/claude.js +3 -3
  33. package/dist/retrofit/planners/codex.js +5 -5
  34. package/dist/retrofit/planners/gemini.js +2 -2
  35. package/dist/retrofit/planners/kilo.js +2 -2
  36. package/dist/retrofit/planners/opencode.js +2 -2
  37. package/dist/retrofit/planners/pi.js +1 -1
  38. package/dist/retrofit/planners/qwen.js +2 -2
  39. package/dist/retrofit/tools.js +9 -7
  40. package/dist/setup/command.js +3 -1
  41. package/docs/CODE-INTELLIGENCE.md +36 -0
  42. package/docs/DASHBOARD-EVOLUTION.md +16 -2
  43. package/docs/DASHBOARD-OVERHAUL.md +146 -0
  44. package/gemini-extension.json +1 -1
  45. package/package.json +1 -1
@@ -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 { parsePeriod, projectAnalytics } from './analytics.js';
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 = 1_048_576;
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, 100), estimate: estimateSchedule(stories, Math.max(1, status?.parallel?.maxConcurrency ?? 1), history) } : {}) };
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 path;
159
+ let requested;
95
160
  try {
96
- path = new URL(req.url ?? '/', origin).pathname;
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
- const match = /^\/api\/projects\/([a-f0-9]{32})(\/pause|\/analytics)?$/u.exec(path);
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(new URL(req.url, origin).searchParams);
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
- if (project.error || !snapshot(project, false).goal) {
153
- send(409, { error: 'No readable project goal' });
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
- safeFile(project.root, '.yoke/goal.pause');
157
- pauseProjectGoal(project.root);
158
- send(200, { status: 'pause-requested' });
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', 'model', 'provider', 'role', 'calls', 'measurementComplete', 'costMeasurementComplete', 'usageAvailable', 'prediction', 'errorMs', 'withinObservedRange', 'escalated'];
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
  }
@@ -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 actions = planRetrofit(canonDir, targetDir, agents, codeGraph);
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 } } : {}),
@@ -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(),
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  export const YOKE_IGNORE_LINES = [
4
4
  '.yoke/worktrees/',
5
5
  '.yoke/backup/',
6
+ '.yoke/code-intelligence/',
6
7
  '.yoke/loop-status.json',
7
8
  '.yoke/loop.log',
8
9
  '.yoke/loop.lock',
@@ -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, _targetDir, codeGraph = 'graphify') {
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, _targetDir, codeGraph = 'graphify') {
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, _targetDir, codeGraph = 'graphify') {
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, _targetDir, codeGraph = 'graphify') {
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, _targetDir, codeGraph = 'graphify') {
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',
@@ -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
- return {
13
- [codeGraph]: CODE_GRAPH_SERVERS[codeGraph],
14
- playwright: { command: 'npx', args: ['@playwright/mcp@latest'] },
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,
@@ -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.