@hecer/yoke 1.17.0 → 1.19.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.
@@ -4,7 +4,8 @@ The autonomous loop is optional and toggle-able:
4
4
 
5
5
  - `yoke loop on` / `yoke loop off` — enable or disable it in `.yoke/config.yaml`.
6
6
  - `yoke loop status` — show enabled state and backlog progress.
7
- - `yoke loop run [--max=N] [--parallel=N] [--isolate] [--decision-policy=auto|critical] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=blocking|advisory] [--quality-unbounded] [--candidates=N]` — run until the current backlog is green or a gate blocks.
7
+ - `yoke loop run [--max=N] [--parallel=N] [--isolate] [--explore] [--explore-interval=N] [--decision-policy=auto|critical] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=blocking|advisory] [--quality-unbounded] [--candidates=N]` — run until the backlog is green or a gate blocks; `--explore` opts into persistent discovery and recovery after each backlog drains.
8
+ - `yoke loop pause` — request a safe-boundary pause from a running loop or exploration supervisor.
8
9
  - `yoke change add --idea="..."` — queue a product change at any time, including while the loop is running.
9
10
  - `yoke loop decision` / `yoke loop answer --choice=<id>` — inspect and answer a structured critical stop.
10
11
 
@@ -13,6 +14,10 @@ story is fast-forwarded to the main tree. Pass `--review` or `--reviewer=<provid
13
14
  a separate, schema-validated review. Pass `--parallel=N` to dispatch ready, non-colliding stories
14
15
  concurrently. Pass `--json` for NDJSON status on stdout.
15
16
 
17
+ With `--explore`, `--explore-limit=12h|3d|2w` optionally stops one invocation after the selected
18
+ duration. The default is unbounded. Expiry stops new work and pauses after active stories complete
19
+ their normal gates and integration; exit code `3` can be resumed with another `yoke loop run`.
20
+
16
21
  Stories may declare a reference, candidate artifact, rubric, and blocking/advisory quality policy.
17
22
  `--quality` runs a read-only blind critic plus bounded repair before review; every repair reruns the
18
23
  mechanical gates. `--candidates=N` requires quality declarations and dispatches multiple isolated
@@ -48,12 +53,24 @@ For each story:
48
53
  7. Only after all gates pass, mark the story `passes: true`, log the decision, and commit
49
54
  atomically. A failed commit restores the PRD state.
50
55
  8. When all current stories pass, run optional `completion.command` against the integrated
51
- system. Only a green result reports `complete`; otherwise the loop blocks. This readiness
52
- result is ephemeral, not a release and not a freeze on future changes.
56
+ system. Only a green result reports `complete`; otherwise the loop blocks. In `--explore` mode
57
+ a green result starts a read-only exploration pass instead of ending the supervisor. A red
58
+ completion gate is supplied as evidence to the explorer so it can propose work to resolve the
59
+ blocker; the gate is never skipped.
60
+ 9. Exploration proposals require repository-file evidence, confidence of at least 0.8, low/medium
61
+ risk, disjoint declared write scopes, and two to five structured acceptance criteria with
62
+ criterion-targeted approved test commands. Yoke validates and commits accepted PRD changes
63
+ before isolated implementation. Passed exploration stories are compacted from the active PRD;
64
+ their full prior contracts remain in Git history and a bounded recent fingerprint ledger
65
+ prevents near-term duplication.
53
66
 
54
67
  A supervisor can pause the loop by creating `.yoke/loop.pause`. The running story finishes;
55
68
  the dispatcher latches the signal, stops launching new workers, lets active workers reach safe
56
69
  terminal proof/cleanup, and exits with code `3` before another story is integrated.
70
+ `--explore` also checks this signal while waiting between scans and during retry backoff. Without
71
+ an explicit `--max` cap, draining the current PRD does not stop exploration; the supervisor waits,
72
+ retries recoverable failures with backoff, and reports the next action. It cannot survive forced
73
+ process or machine termination.
57
74
 
58
75
  State lives outside model context: PRD, git, and the ignored `.yoke/changes/` inbox. All are
59
76
  re-read at story boundaries, so a request queued mid-run becomes additional stories without a
@@ -1,5 +1,5 @@
1
1
  name: yoke-canon
2
- version: 1.17.0
2
+ version: 1.19.0
3
3
  agents: [claude, codex, gemini, qwen, opencode, kilo, pi, hermes]
4
4
  skills:
5
5
  - { id: tdd, path: skills/tdd, kind: methodology, invocation: auto }
@@ -6,6 +6,7 @@ import { createBoundedOutput, createTelemetryAccumulator } from './process-strea
6
6
  import { processIncarnation } from './process-incarnation.js';
7
7
  import { prepareWindowsInvocation, resolveWindowsCommand } from './windows-launch.js';
8
8
  import { createSupervision, supervisionLimits, assertPreviousProvidersStopped } from './supervision.js';
9
+ import { prepareSolPiTemporaryConfig } from './sol-pi-runtime.js';
9
10
  function cancellationReason(signal) {
10
11
  return typeof signal.reason === 'string' && signal.reason.length > 0
11
12
  ? signal.reason
@@ -26,25 +27,48 @@ export function startProviderProcess(agent, invocation, options = {}) {
26
27
  let progress = () => { };
27
28
  const limits = supervisionLimits(invocation.cwd);
28
29
  const supervision = createSupervision(invocation.cwd, reason => failure(reason), () => progress(), options.attempt);
30
+ let restoreSolPiConfig = () => { };
29
31
  let prepared;
30
32
  try {
33
+ if (agent === 'pi')
34
+ restoreSolPiConfig = prepareSolPiTemporaryConfig(invocation.cwd);
31
35
  assertPreviousProvidersStopped(invocation.cwd);
32
36
  prepared = process.platform === 'win32' ? prepareWindowsInvocation(invocation) : { command: invocation.command, args: invocation.args, env: process.env };
33
37
  }
34
38
  catch (error) {
35
- const message = error.message;
39
+ let message = error.message;
40
+ try {
41
+ restoreSolPiConfig();
42
+ }
43
+ catch (cleanupError) {
44
+ message = cleanupError.message;
45
+ }
36
46
  supervision.stop(message);
37
47
  return { pid: undefined, invocation, recordPath: '', cancel: () => false, completion: Promise.resolve({ kind: 'spawn-failed', error: message, invocation, pid: undefined, stdout: '', stderr: '', stdoutTruncated: false, stderrTruncated: false, telemetry: { usageAvailable: false } }) };
38
48
  }
39
49
  const spawnOptions = { ...prepared, cwd: invocation.cwd, shell: false, detached: process.platform !== 'win32' };
40
- const child = spawn(spawnOptions.command, [...spawnOptions.args], {
41
- cwd: spawnOptions.cwd,
42
- shell: spawnOptions.shell,
43
- stdio: ['pipe', 'pipe', 'pipe'],
44
- detached: spawnOptions.detached,
45
- env: prepared.env,
46
- windowsHide: true,
47
- });
50
+ let child;
51
+ try {
52
+ child = spawn(spawnOptions.command, [...spawnOptions.args], {
53
+ cwd: spawnOptions.cwd,
54
+ shell: spawnOptions.shell,
55
+ stdio: ['pipe', 'pipe', 'pipe'],
56
+ detached: spawnOptions.detached,
57
+ env: prepared.env,
58
+ windowsHide: true,
59
+ });
60
+ }
61
+ catch (error) {
62
+ let message = error.message;
63
+ try {
64
+ restoreSolPiConfig();
65
+ }
66
+ catch (cleanupError) {
67
+ message = cleanupError.message;
68
+ }
69
+ supervision.stop(message);
70
+ return { pid: undefined, invocation, recordPath: '', cancel: () => false, completion: Promise.resolve({ kind: 'spawn-failed', error: message, invocation, pid: undefined, stdout: '', stderr: '', stdoutTruncated: false, stderrTruncated: false, telemetry: { usageAvailable: false } }) };
71
+ }
48
72
  const targetDir = resolve(invocation.cwd);
49
73
  const pid = child.pid;
50
74
  const startedAt = pid === undefined ? `unverified:${new Date().toISOString()}` : processIncarnation(pid) ?? `unverified:${new Date().toISOString()}`;
@@ -130,11 +154,18 @@ export function startProviderProcess(agent, invocation, options = {}) {
130
154
  return;
131
155
  settled = true;
132
156
  clearTimers();
133
- supervision.stop(termination?.reason ?? (result.kind === 'succeeded' ? 'provider-exited' : 'provider-failed'), !termination || terminationConfirmed);
157
+ let completedResult = result;
158
+ try {
159
+ restoreSolPiConfig();
160
+ }
161
+ catch (error) {
162
+ completedResult = { ...evidence(), kind: 'spawn-failed', error: error.message };
163
+ }
164
+ supervision.stop(termination?.reason ?? (completedResult.kind === 'succeeded' ? 'provider-exited' : 'provider-failed'), !termination || terminationConfirmed);
134
165
  options.signal?.removeEventListener('abort', onAbort);
135
166
  if (!termination || terminationConfirmed)
136
167
  removeRecord();
137
- resolveCompletion(result);
168
+ resolveCompletion(completedResult);
138
169
  };
139
170
  const evidence = () => ({
140
171
  invocation,
@@ -1,5 +1,7 @@
1
1
  import { ModelSelectionSchema } from './contracts.js';
2
2
  import { fileURLToPath } from 'node:url';
3
+ import { loadSolPiProjectConfig } from './sol-pi-runtime.js';
4
+ const SOL_PI_EXTENSION = 'git:github.com/NVlabs/SoL-Pi@d7ecfc089944f0d04b80122a0a9a6ca0d786f3d0';
3
5
  export { providerSpawnOptions, startProviderProcess, } from './process.js';
4
6
  const argsFor = (agent, permissions) => {
5
7
  if (agent === 'claude') {
@@ -76,6 +78,13 @@ export function buildProviderInvocation(agent, prompt, cwd, permissions = 'safe'
76
78
  if (parsedSelection.variant && !['opencode', 'kilo', 'pi', 'hermes'].includes(agent))
77
79
  throw new Error(`${agent} does not support a model variant selection`);
78
80
  const args = argsFor(agent, permissions);
81
+ if (agent === 'pi' && loadSolPiProjectConfig(cwd)?.solpi?.enabled) {
82
+ const [major, minor] = process.version.slice(1).split('.').map(Number);
83
+ if (major < 22 || (major === 22 && minor < 19)) {
84
+ throw new Error(`SoL-Pi requires Node.js 22.19 or newer; current version is ${process.version}`);
85
+ }
86
+ args.push('--extension', SOL_PI_EXTENSION);
87
+ }
79
88
  if (output.schemaFile !== undefined || output.jsonSchema !== undefined) {
80
89
  if (agent === 'codex' && output.schemaFile && output.jsonSchema === undefined) {
81
90
  if (/[\0\r\n]/u.test(output.schemaFile) || (process.platform === 'win32' && !/^[A-Za-z0-9_./:\\-]+$/u.test(output.schemaFile)))
@@ -119,7 +128,7 @@ export function buildProviderInvocation(agent, prompt, cwd, permissions = 'safe'
119
128
  else if (agent === 'pi')
120
129
  args.push('--thinking', parsedSelection.reasoningEffort);
121
130
  else if (agent === 'hermes')
122
- args.push('--reasoning-effort', parsedSelection.reasoningEffort);
131
+ args.push('--reasoning', parsedSelection.reasoningEffort);
123
132
  }
124
133
  if (parsedSelection.variant) {
125
134
  if ((agent === 'opencode' || agent === 'kilo') && !parsedSelection.reasoningEffort)
@@ -134,7 +143,7 @@ export function buildProviderInvocation(agent, prompt, cwd, permissions = 'safe'
134
143
  if (parsedSelection.reasoningEffort && parsedSelection.reasoningEffort !== parsedSelection.variant)
135
144
  throw new Error('Hermes reasoningEffort and variant selections must match');
136
145
  if (!parsedSelection.reasoningEffort)
137
- args.push('--reasoning-effort', parsedSelection.variant);
146
+ args.push('--reasoning', parsedSelection.variant);
138
147
  }
139
148
  }
140
149
  if (agent === 'codex' && parsedSelection.nativeMultiAgent === false)
@@ -0,0 +1,76 @@
1
+ import { lstatSync, mkdirSync, readFileSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { loadConfig, toSolPiNativeConfig } from '../retrofit/config.js';
4
+ export function loadSolPiProjectConfig(directory) {
5
+ let current = resolve(directory);
6
+ while (true) {
7
+ const config = loadConfig(current);
8
+ if (config)
9
+ return config;
10
+ const parent = dirname(current);
11
+ if (parent === current)
12
+ return null;
13
+ current = parent;
14
+ }
15
+ }
16
+ function lstatIfPresent(path) {
17
+ try {
18
+ return lstatSync(path);
19
+ }
20
+ catch (error) {
21
+ if (error.code === 'ENOENT')
22
+ return undefined;
23
+ throw error;
24
+ }
25
+ }
26
+ function assertNoConfigSymlinks(paths) {
27
+ for (const path of paths) {
28
+ if (lstatIfPresent(path)?.isSymbolicLink())
29
+ throw new Error('SoL-Pi refuses symbolic-link config paths: ' + path);
30
+ }
31
+ }
32
+ function removeCreatedDirectories(directories) {
33
+ for (const directory of [...directories].reverse()) {
34
+ try {
35
+ rmdirSync(directory);
36
+ }
37
+ catch (error) {
38
+ const code = error.code;
39
+ if (code !== 'ENOENT' && code !== 'ENOTEMPTY')
40
+ throw error;
41
+ }
42
+ }
43
+ }
44
+ export function prepareSolPiTemporaryConfig(workspace) {
45
+ const config = loadSolPiProjectConfig(workspace);
46
+ if (!config?.solpi?.enabled)
47
+ return () => { };
48
+ const piDirectory = resolve(workspace, '.pi');
49
+ const solPiConfigPath = join(piDirectory, 'sol-pi.json');
50
+ const configPaths = [piDirectory, solPiConfigPath];
51
+ assertNoConfigSymlinks(configPaths);
52
+ const existingConfig = lstatIfPresent(solPiConfigPath) ? readFileSync(solPiConfigPath) : undefined;
53
+ const createdDirectories = [];
54
+ const restore = () => {
55
+ assertNoConfigSymlinks(configPaths);
56
+ if (existingConfig === undefined)
57
+ rmSync(solPiConfigPath, { force: true });
58
+ else
59
+ writeFileSync(solPiConfigPath, existingConfig);
60
+ removeCreatedDirectories(createdDirectories);
61
+ };
62
+ try {
63
+ for (const directory of [piDirectory]) {
64
+ if (!lstatIfPresent(directory)) {
65
+ mkdirSync(directory);
66
+ createdDirectories.push(directory);
67
+ }
68
+ }
69
+ writeFileSync(solPiConfigPath, JSON.stringify(toSolPiNativeConfig(config)), { flag: existingConfig === undefined ? 'wx' : 'w' });
70
+ }
71
+ catch (error) {
72
+ restore();
73
+ throw error;
74
+ }
75
+ return restore;
76
+ }
package/dist/cli.js CHANGED
@@ -9,6 +9,7 @@ import { createProjectGoal, readProjectGoal, runProjectGoal, pauseProjectGoal, g
9
9
  import { validateCanon } from './canon/validate.js';
10
10
  import { runRetrofit } from './retrofit/command.js';
11
11
  import { setLoopEnabled, loopStatus, runLoopCommand } from './loop/run-command.js';
12
+ import { requestLoopPause } from './loop/loop.js';
12
13
  import { runContextInit, runContextStatus } from './context/command.js';
13
14
  import { runReview } from './review/command.js';
14
15
  import { scanDir } from './scan/design.js';
@@ -103,6 +104,33 @@ export function parseQualityFlags(args) {
103
104
  },
104
105
  };
105
106
  }
107
+ export function parseExploreLimit(value) {
108
+ const match = /^(\d+)\s*(h|hours?|d|days?|w|weeks?)$/i.exec(value.trim());
109
+ if (!match)
110
+ return { ok: false, error: `Invalid --explore-limit value: ${value} (expected a positive duration such as 12h, 3d, or 2w)` };
111
+ const amount = Number(match[1]);
112
+ const unit = match[2].toLowerCase();
113
+ const unitMs = unit.startsWith('h') ? 60 * 60_000 : unit.startsWith('d') ? 24 * 60 * 60_000 : 7 * 24 * 60 * 60_000;
114
+ const milliseconds = amount * unitMs;
115
+ if (!Number.isSafeInteger(amount) || amount <= 0 || !Number.isSafeInteger(milliseconds) || Date.now() + milliseconds > 8_640_000_000_000_000) {
116
+ return { ok: false, error: `Invalid --explore-limit value: ${value} (duration must be positive and within the supported date range)` };
117
+ }
118
+ return { ok: true, milliseconds };
119
+ }
120
+ function parseExploreLimitFlag(args, explore) {
121
+ const values = args.filter(arg => arg.startsWith('--explore-limit='));
122
+ if (args.includes('--explore-limit'))
123
+ return { ok: false, error: '--explore-limit requires a duration, for example --explore-limit=12h' };
124
+ if (values.length > 1)
125
+ return { ok: false, error: 'Use --explore-limit only once per invocation' };
126
+ const raw = values[0];
127
+ if (!raw)
128
+ return { ok: true };
129
+ if (!explore)
130
+ return { ok: false, error: '--explore-limit requires --explore' };
131
+ const parsed = parseExploreLimit(raw.slice('--explore-limit='.length));
132
+ return parsed.ok ? { ok: true, milliseconds: parsed.milliseconds } : parsed;
133
+ }
106
134
  export function main(argv) {
107
135
  const [cmd, ...rest] = argv;
108
136
  switch (cmd) {
@@ -358,6 +386,11 @@ export function main(argv) {
358
386
  console.log(loopStatus(targetDir));
359
387
  return 0;
360
388
  }
389
+ if (sub === 'pause') {
390
+ requestLoopPause(targetDir);
391
+ console.log('Pause requested at the next safe story or exploration boundary.');
392
+ return 0;
393
+ }
361
394
  if (sub === 'cleanup')
362
395
  return runLoopCleanup(targetDir, {
363
396
  removeWorktrees: rest.includes('--remove-worktrees'),
@@ -414,7 +447,23 @@ export function main(argv) {
414
447
  console.error(qualityFlags.error);
415
448
  return 1;
416
449
  }
417
- return runLoopCommand(targetDir, { ...resumeOptions, ...qualityFlags.options });
450
+ const explore = rest.includes('--explore');
451
+ const exploreLimit = parseExploreLimitFlag(rest, explore);
452
+ if (!exploreLimit.ok) {
453
+ console.error(exploreLimit.error);
454
+ return 1;
455
+ }
456
+ const exploreArg = rest.find(a => a.startsWith('--explore-interval='));
457
+ if (exploreArg && !explore) {
458
+ console.error('--explore-interval requires --explore');
459
+ return 1;
460
+ }
461
+ const exploreIntervalMinutes = exploreArg ? Number(exploreArg.slice('--explore-interval='.length)) : undefined;
462
+ if (exploreIntervalMinutes !== undefined && (!Number.isInteger(exploreIntervalMinutes) || exploreIntervalMinutes < 1 || exploreIntervalMinutes > 1_440)) {
463
+ console.error(`Invalid --explore-interval value: ${exploreArg} (expected 1..1440 minutes)`);
464
+ return 1;
465
+ }
466
+ return runLoopCommand(targetDir, { ...resumeOptions, ...qualityFlags.options, ...(explore ? { explore: true } : {}), ...(exploreIntervalMinutes !== undefined ? { exploreIntervalMinutes } : {}), ...(exploreLimit.milliseconds !== undefined ? { exploreLimitMs: exploreLimit.milliseconds } : {}) });
418
467
  }
419
468
  if (sub === 'answer') {
420
469
  const choice = rest.find(a => a.startsWith('--choice='))?.slice('--choice='.length);
@@ -543,9 +592,29 @@ export function main(argv) {
543
592
  console.error(qualityFlags.error);
544
593
  return 1;
545
594
  }
546
- return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, resumeWorktree: rest.includes('--resume-worktree'), parallel, parallelAuto: parallelArg === '--parallel=auto', reviewer, review, allowSelfReview, timeoutMinutes, json, routing, onAmbiguity: oaArg, decisionPolicy: dpArg, permissions, ...qualityFlags.options });
595
+ const explore = rest.includes('--explore');
596
+ const exploreLimit = parseExploreLimitFlag(rest, explore);
597
+ if (!exploreLimit.ok) {
598
+ console.error(exploreLimit.error);
599
+ return 1;
600
+ }
601
+ const exploreArg = rest.find(a => a.startsWith('--explore-interval='));
602
+ if (exploreArg && !explore) {
603
+ console.error('--explore-interval requires --explore');
604
+ return 1;
605
+ }
606
+ const exploreIntervalMinutes = exploreArg ? Number(exploreArg.slice('--explore-interval='.length)) : undefined;
607
+ if (exploreIntervalMinutes !== undefined && (!Number.isInteger(exploreIntervalMinutes) || exploreIntervalMinutes < 1 || exploreIntervalMinutes > 1_440)) {
608
+ console.error(`Invalid --explore-interval value: ${exploreArg} (expected 1..1440 minutes)`);
609
+ return 1;
610
+ }
611
+ if (explore && rest.includes('--resume-worktree')) {
612
+ console.error('--explore starts its own safe resume cycle; remove --resume-worktree and re-run it after resolving the retained story.');
613
+ return 1;
614
+ }
615
+ return runLoopCommand(targetDir, { maxIterations: rawMax, agent, isolate, resumeWorktree: rest.includes('--resume-worktree'), parallel, parallelAuto: parallelArg === '--parallel=auto', reviewer, review, allowSelfReview, timeoutMinutes, json, routing, onAmbiguity: oaArg, decisionPolicy: dpArg, permissions, ...(explore ? { explore: true } : {}), ...(exploreIntervalMinutes !== undefined ? { exploreIntervalMinutes } : {}), ...(exploreLimit.milliseconds !== undefined ? { exploreLimitMs: exploreLimit.milliseconds } : {}), ...qualityFlags.options });
547
616
  }
548
- console.log(`usage: yoke loop <on|off|status|decision|answer|resume [--discard] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N]|cleanup [--remove-worktrees] [--discard-stale-recovery]|run [--max=N] [--parallel=<auto|N>] [--runner=<${AGENT_LIST}>] [--reviewer=<${AGENT_LIST}>] [--review] [--allow-self-review] [--routing|--no-routing] [--isolate|--no-isolate] [--unsafe] [--timeout=<minutes>] [--decision-policy=<auto|critical>] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N] [--json]> [targetDir]`);
617
+ console.log(`usage: yoke loop <on|off|status|pause|decision|answer|resume [--discard] [--explore] [--explore-interval=<minutes>] [--explore-limit=<Nh|Nd|Nw>] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N]|cleanup [--remove-worktrees] [--discard-stale-recovery]|run [--max=N] [--explore] [--explore-interval=<minutes>] [--explore-limit=<Nh|Nd|Nw>] [--parallel=<auto|N>] [--runner=<${AGENT_LIST}>] [--reviewer=<${AGENT_LIST}>] [--review] [--allow-self-review] [--routing|--no-routing] [--isolate|--no-isolate] [--unsafe] [--timeout=<minutes>] [--decision-policy=<auto|critical>] [--quality|--no-quality] [--quality-rounds=N] [--quality-minutes=N] [--quality-policy=<blocking|advisory>] [--quality-unbounded] [--candidates=N] [--json]> [targetDir]`);
549
618
  return 1;
550
619
  }
551
620
  case 'new': {
@@ -2,7 +2,7 @@ import { McpBackendAdapter } from './mcp.js';
2
2
  export const GRAPHIFY_VERSION = '0.9.56';
3
3
  export function createGraphifyAdapter(cwd, override) {
4
4
  return new McpBackendAdapter({
5
- name: 'graphify', version: GRAPHIFY_VERSION, cwd, command: override?.command ?? 'python', args: override?.args ?? ['-m', 'graphify.serve'], framing: 'content-length', semantic: false, documents: true,
5
+ name: 'graphify', version: GRAPHIFY_VERSION, cwd, command: override?.command ?? 'python', args: override?.args ?? ['-m', 'graphify.serve'], framing: 'line', semantic: false, documents: true,
6
6
  aliases: { context: 'query_graph', symbol: 'get_node', neighbors: 'get_neighbors', trace: 'shortest_path', impact: 'get_pr_impact' },
7
7
  });
8
8
  }
@@ -2,16 +2,37 @@ import { McpStdioClient } from '../mcp-client.js';
2
2
  function valueFromResult(value) {
3
3
  if (!value || typeof value !== 'object')
4
4
  return value;
5
- if (value.structuredContent !== undefined)
6
- return value.structuredContent;
5
+ if (value.isError === true)
6
+ throw new Error('MCP backend tool returned an error');
7
+ if (value.structuredContent !== undefined) {
8
+ const structured = value.structuredContent;
9
+ if (structured && typeof structured === 'object' && Object.keys(structured).length === 1 && typeof structured.result === 'string') {
10
+ try {
11
+ return JSON.parse(structured.result);
12
+ }
13
+ catch {
14
+ return structured.result;
15
+ }
16
+ }
17
+ return structured;
18
+ }
7
19
  const text = Array.isArray(value.content) ? value.content.find((item) => item?.type === 'text')?.text : undefined;
8
20
  if (typeof text !== 'string')
9
21
  return value;
10
22
  try {
11
- return JSON.parse(text);
23
+ const parsed = JSON.parse(text);
24
+ if (parsed && typeof parsed === 'object' && Object.keys(parsed).length === 1 && typeof parsed.result === 'string') {
25
+ try {
26
+ return JSON.parse(parsed.result);
27
+ }
28
+ catch {
29
+ return parsed.result;
30
+ }
31
+ }
32
+ return parsed;
12
33
  }
13
34
  catch {
14
- return { text };
35
+ return text;
15
36
  }
16
37
  }
17
38
  export class McpBackendAdapter {
@@ -2,7 +2,7 @@ import { McpBackendAdapter } from './mcp.js';
2
2
  export const SERENA_VERSION = '1.7.1-dev';
3
3
  export function createSerenaAdapter(cwd, override) {
4
4
  return new McpBackendAdapter({
5
- name: 'serena-lsp', version: SERENA_VERSION, cwd, command: override?.command ?? 'serena', args: override?.args ?? ['start-mcp-server', '--project-from-cwd', '--context', 'codex'], framing: 'content-length', semantic: true, documents: false,
5
+ name: 'serena-lsp', version: SERENA_VERSION, cwd, command: override?.command ?? 'serena', args: override?.args ?? ['start-mcp-server', '--project-from-cwd', '--context', 'codex'], framing: 'line', semantic: true, documents: false,
6
6
  aliases: {
7
7
  symbol: 'find_symbol', references: 'find_referencing_symbols', implementations: 'find_implementations', overview: 'get_symbols_overview', diagnostics: 'get_diagnostics_for_file',
8
8
  rename: 'rename_symbol', replace_symbol_body: 'replace_symbol_body', insert_before_symbol: 'insert_before_symbol', insert_after_symbol: 'insert_after_symbol', safe_delete: 'safe_delete_symbol',
@@ -185,9 +185,10 @@ export class CodeIntelligenceCoordinator {
185
185
  const name = input.symbol_id ?? input.query;
186
186
  const reference = symbolArgs(name, input.path ?? '');
187
187
  const graft = await this.call('graft', 'context', { query: name, limit: 10, ...(input.path ? { in: input.path } : {}) }, input.timeout_ms, state);
188
+ // Graft returns search prose, not structured symbol definitions. Preserve
189
+ // its provenance without inventing a symbol from the response heading.
188
190
  if (graft !== null) {
189
191
  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
192
  provenance.push(...normalized.provenance);
192
193
  }
193
194
  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);
@@ -12,7 +12,7 @@ function textOf(raw) {
12
12
  }
13
13
  }
14
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);
15
+ const match = /(?:^|[\s("'`])((?:[A-Za-z0-9_.@-]+[\\/])*[A-Za-z0-9_.@-]+\.[A-Za-z0-9]+):L?(\d+)/u.exec(text);
16
16
  if (!match)
17
17
  return { path: null, line: null };
18
18
  try {
@@ -44,6 +44,17 @@ export function normalizeItems(raw, options, kind = 'code', limit = 100) {
44
44
  if (!excerpt)
45
45
  continue;
46
46
  const p = makeProvenance(options, excerpt, excerpt.toLowerCase().includes('ambiguous') ? 'ambiguous' : 'resolved');
47
+ if (candidate && typeof candidate === 'object' && typeof candidate.relative_path === 'string') {
48
+ try {
49
+ p.source_path = assertSafePath(options.root, candidate.relative_path);
50
+ p.content_hash = options.snapshot.files.find(file => file.path === p.source_path)?.hash ?? null;
51
+ }
52
+ catch {
53
+ p.source_path = null;
54
+ p.content_hash = null;
55
+ p.resolution = 'unresolved';
56
+ }
57
+ }
47
58
  const id = evidenceId(p, excerpt);
48
59
  provenance.push(p);
49
60
  items.push({ item_id: `item-${hash(excerpt).slice(0, 24)}`, kind, path: p.source_path, excerpt, evidence_ids: [id], rank: items.length });
@@ -1,6 +1,6 @@
1
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). */
2
+ /** Small, bounded stdio MCP client. Supports newline JSON-RPC used by the
3
+ * backend SDKs and optional Content-Length framing for legacy peers. */
4
4
  export class McpStdioClient {
5
5
  command;
6
6
  args;
@@ -33,7 +33,7 @@ function validDate(value){if(!/^\\d{4}-\\d{2}-\\d{2}$/u.test(value))return false
33
33
  function parseNavigationHash(hash){
34
34
  const params=new URLSearchParams(String(hash||'').replace(/^#/u,''));const screen=params.get('screen')||'overview',project=params.get('project'),view=params.get('view')||'now',period=Number(params.get('period')||30),group=params.get('group')||'day',from=params.get('from')||'',to=params.get('to')||'',query=params.get('query')||'',status=params.get('status')||'all',sort=params.get('sort')||'attention';
35
35
  const datesValid=(!from&&!to)||(validDate(from)&&validDate(to)&&from<=to&&Date.parse(to+'T00:00:00Z')-Date.parse(from+'T00:00:00Z')<=365*86400000);
36
- if(!['overview','workspace','project'].includes(screen)||!['now','usage','results','history'].includes(view)||![1,7,30,90,365].includes(period)||!['day','week','month'].includes(group)||!['all','active','attention'].includes(status)||!['attention','activity','tokens','cost','acceptance','name'].includes(sort)||query.length>200||!datesValid||(screen==='project'&&!/^[a-f0-9]{32}$/u.test(project||'')))return {...defaultNavigation};
36
+ if(!['overview','workspace','project'].includes(screen)||!['now','usage','results','history','settings'].includes(view)||![1,7,30,90,365].includes(period)||!['day','week','month'].includes(group)||!['all','active','attention'].includes(status)||!['attention','activity','tokens','cost','acceptance','name'].includes(sort)||query.length>200||!datesValid||(screen==='project'&&!/^[a-f0-9]{32}$/u.test(project||'')))return {...defaultNavigation};
37
37
  const state={screen,project:screen==='project'?project:null,view,period,group,from,to};if(params.has('query'))state.query=query;if(params.has('status'))state.status=status;if(params.has('sort'))state.sort=sort;return state;
38
38
  }
39
39
  function serializeNavigationHash(state){const params=new URLSearchParams({screen:state.screen||'overview',view:state.view||'now',period:String(state.period||30),group:state.group||'day'});if(state.project)params.set('project',state.project);if(state.from&&state.to){params.set('from',state.from);params.set('to',state.to)}if(state.query)params.set('query',state.query);if(state.status&&state.status!=='all')params.set('status',state.status);if(state.sort&&state.sort!=='attention')params.set('sort',state.sort);return '#'+params.toString()}
@@ -125,25 +125,47 @@ function renderUsageHistory(current,previous,project={}){
125
125
  }
126
126
  function renderResults(p,a){const t=a.total;content.append(append(el('div',undefined,'metrics'),metric('Accepted in period',t.accepted,'Recorded acceptance events'),metric('Repair phases',t.repairs,'Routing escalations: '+t.escalations),metric('Tokens / acceptance',rate(t.tokensPerAccepted),'Reported input + output, including failed work')));const attempts=panel('Measured outcomes');attempts.append(el('p','Ended attempts: '+t.attempts+' · explicitly successful attempts: '+t.successfulAttempts+' · average summed attempt time per acceptance: '+duration(t.timePerAcceptedMs)));attempts.append(el('p','Worker termination alone is not counted as successful acceptance. Rates only describe recorded activity in the selected period.','muted'));attempts.append(table(['Task','Attempts','Accepted','Repairs','Escalations','Tokens / acceptance'],a.tasks.map(v=>[v.storyId,v.attempts,v.accepted,v.repairs,v.escalations,rate(v.tokensPerAccepted)])));content.append(attempts);const checks=panel('Latest saved acceptance evidence');if(p.check){checks.append(el('p',p.check.summary));for(const c of p.check.criteria){const item=row(c.text,c.id,c.status);item.firstChild.append(append(el('details'),el('summary','View evidence'),el('p',c.summary)));checks.append(item)}checks.append(el('p','Checked '+p.check.generatedAt+' · historical evidence; independent of the selected statistics period.','muted'))}else checks.append(el('p','No saved independent check.','empty'));content.append(checks);const goals=panel('Saved goal attempts');for(const attempt of p.goal?.attempts||[])goals.append(row(attempt.provider||'Agent',(attempt.summary||'')+' · '+duration(attempt.durationMs)+' · input '+tokenText(attempt.inputTokens)+' / output '+tokenText(attempt.outputTokens),attempt.success?'finished':'failed'));content.append(goals)}
127
127
  function renderHistoryExplorer(project,history){const state=projectStatus(project),view=panel('History explorer');view.append(el('p','Recorded events and completed runs in the selected UTC window. The list is bounded to the latest 100 events.','muted'));const events=timelineEvents(history.events||[]);if(!events.length)view.append(el('p','No recorded history in this period.','empty'));else{const list=el('div',undefined,'timeline');for(const event of events)list.append(timelineItem(event));view.append(list)}const runs=panel('Run history');if(!(history.runs||[]).length)runs.append(el('p','No recorded runs in this period.','empty'));else runs.append(table(['Run','Started','Ended','Agent','Provider','Model','Tokens','Accepted','Outcome'],history.runs.map(run=>[dimensionLabel(run.runId),run.startedAt||'Not reported',run.endedAt||'Not reported',dimensionLabel(run.agent),dimensionLabel(run.provider),dimensionLabel(run.model),rankingText(run.total?.tokens?.total),rankingText(run.total?.outcomes?.accepted),rankingText(run.total?.outcomes?.successfulAttempts)])));content.append(renderCoverage(history,{stale:state.stale,unavailable:false}),runs,view)}
128
+ function solPiRatioError(value){const text=String(value);const ratio=Number(text);return text.trim()===''||!Number.isFinite(ratio)||ratio<0?'Enter a finite cache write/read ratio of 0 or more.':null}
129
+ async function saveSolPiSettings(projectId,settings,feedback){
130
+ const error=solPiRatioError(settings.cacheWriteReadRatio);if(error){feedback.className='settings-feedback notice';feedback.textContent=error;return}
131
+ feedback.className='settings-feedback muted';feedback.textContent='Saving settings…';
132
+ try{await api('/api/projects/'+projectId+'/settings',{method:'POST',headers:{'content-type':'application/json','x-yoke-token':sessionToken},body:JSON.stringify({...settings,cacheWriteReadRatio:Number(settings.cacheWriteReadRatio)})});feedback.className='settings-feedback muted';feedback.textContent='Settings saved.'}
133
+ catch(error){feedback.className='settings-feedback notice';feedback.textContent='Could not save settings: '+error.message}
134
+ }
135
+ function renderSolPiSettings(project,settings){
136
+ const view=panel('SoL-Pi settings');view.append(el('p','SoL-Pi is off by default. Change the master switch and any mechanism switches, then save.','muted'));
137
+ const controls={},toggles=[['enabled','Enable SoL-Pi (off by default)'],['actionFusion','Action fusion'],['observationPack','Observation packing'],['evidencePreservingReducer','Evidence-preserving reducer'],['onlineContextCompact','Online context compaction']];
138
+ for(const [key,label] of toggles){const input=el('input');input.type='checkbox';input.checked=settings[key]===true;input.setAttribute('role','switch');input.setAttribute('aria-label',label);controls[key]=input;view.append(append(el('label',undefined,'setting-toggle'),input,el('span',label)))}
139
+ const ratioLabel=el('label',undefined,'field'),ratio=el('input');ratio.type='number';ratio.min='0';ratio.step='any';ratio.value=String(settings.cacheWriteReadRatio);ratio.setAttribute('aria-label','Cache write/read ratio');ratioLabel.append(el('span','Cache write/read ratio','label'),ratio);view.append(ratioLabel);
140
+ view.append(el('p','The pinned SoL-Pi extension executes inside Pi. Review the pinned SoL-Pi extension before enabling it.','muted'));
141
+ view.append(el('p','SoL-Pi needs a compatible Pi runtime installed and configured; Yoke does not install or authenticate Pi. For headless runs, Pi requires explicit project trust before it loads project-local settings. Open Pi, run /trust, then restart. Yoke does not grant trust.','muted'));
142
+ view.append(el('p','The evidence-preserving reducer may make a model call using diagnostic logs; those logs can contain project data. Review the selected provider and model before enabling it.','muted'));
143
+ const feedback=el('p','Save your changes to apply them.','settings-feedback muted');feedback.setAttribute('role','status');feedback.setAttribute('aria-live','polite');view.append(feedback);
144
+ view.append(button('Save settings',()=>saveSolPiSettings(project.id,{enabled:controls.enabled.checked,actionFusion:controls.actionFusion.checked,observationPack:controls.observationPack.checked,evidencePreservingReducer:controls.evidencePreservingReducer.checked,onlineContextCompact:controls.onlineContextCompact.checked,cacheWriteReadRatio:ratio.value},feedback),true));
145
+ return view
146
+ }
128
147
  async function showProject(id){
129
148
  const request=beginRequest();selected=id;nav();
130
149
  try{
131
150
  const bounds=periodWindows(Date.now(),periodDays,customFrom,customTo),detailRequest=api('/api/projects/'+id,{},request.signal);
132
- let project,current=null,previous=null,history=null;
151
+ let project,current=null,previous=null,history=null,settings=null;
133
152
  if(projectView==='usage'){
134
153
  [project,current,previous]=await Promise.all([detailRequest,api('/api/projects/'+id+'/analytics?'+queryPeriod(bounds.current),{},request.signal),api('/api/projects/'+id+'/analytics?'+queryPeriod(bounds.previous),{},request.signal)]);
135
154
  }else if(projectView==='results'){
136
155
  [project,current]=await Promise.all([detailRequest,api('/api/projects/'+id+'/analytics?'+queryPeriod(bounds.current),{},request.signal)]);
137
156
  }else if(projectView==='history'){
138
157
  [project,history]=await Promise.all([detailRequest,api('/api/projects/'+id+'/history?'+queryPeriod(bounds.current)+'&limit=100',{},request.signal)]);
158
+ }else if(projectView==='settings'){
159
+ [project,settings]=await Promise.all([detailRequest,api('/api/projects/'+id+'/settings',{},request.signal)]);
139
160
  }else project=await detailRequest;
140
161
  if(!currentRequest(request.version)||navigationState.screen!=='project'||selected!==id)return;
141
162
  lastSuccessfulSync=new Date().toISOString();heading(project.name,project.goal?.objective||'Saved project work');content.append(el('p',project.root,'path'));for(const error of project.errors)content.append(el('p',error,'notice'));
142
- const tabs=el('div',undefined,'actions');for(const [view,label] of [['now','Now'],['usage','Usage & time'],['results','Results'],['history','History']]){const control=button(label,()=>setNavigation({view}),projectView===view);control.setAttribute('aria-pressed',String(projectView===view));tabs.append(control)}
163
+ const tabs=el('div',undefined,'actions');for(const [view,label] of [['now','Now'],['usage','Usage & time'],['results','Results'],['history','History'],['settings','Settings']]){const control=button(label,()=>setNavigation({view}),projectView===view);control.setAttribute('aria-pressed',String(projectView===view));tabs.append(control)}
143
164
  content.append(tabs);
144
165
  if(projectView==='now'){
145
166
  const tasks=project.stories||[],state=projectStatus(project);content.append(append(el('div',undefined,'metrics'),metric('Accepted tasks',tasks.filter(task=>task.passes).length+' / '+tasks.length,'Saved acceptance state'),metric('Remaining time',project.estimate?.available?duration(project.estimate.lowerMs)+' – '+duration(project.estimate.upperMs):'Unknown',project.estimate?.available?project.estimate.sampleCount+' samples · empirical range':'No reliable duration history'),metric('Project status',state.primary,state.goal&&state.loop&&state.goal!==state.loop?'Goal '+state.goal+' · loop '+(state.stale?'unconfirmed':state.loop):'Latest reported state')));renderNow(project);
146
- }else{periodControls(id);if(projectView==='usage')renderUsageHistory(current,previous,projectStatus(project));else if(projectView==='history')renderHistoryExplorer(project,history);else renderResults(project,current)}
167
+ }else if(projectView==='settings')content.append(renderSolPiSettings(project,settings));
168
+ else{periodControls(id);if(projectView==='usage')renderUsageHistory(current,previous,projectStatus(project));else if(projectView==='history')renderHistoryExplorer(project,history);else renderResults(project,current)}
147
169
  }catch(error){if(currentRequest(request.version)&&navigationState.screen==='project'&&selected===id&&!isAbort(error)){heading('Project unavailable','Offline or unavailable: '+error.message);content.append(renderCoverage({errors:[],coverage:'Historical analytics are unavailable for this project.'},{unavailable:true}))}}
148
170
  }
149
171
  if(typeof setInterval==='function')setInterval(()=>{if(selected&&projectView==='now'&&typeof document.hidden!=='undefined'&&!document.hidden&&!['INPUT','SELECT','TEXTAREA'].includes(document.activeElement?.tagName)&&!document.activeElement?.matches?.(':focus-visible'))showProject(selected)},5000);