@hecer/yoke 1.10.0 → 1.12.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/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { MODEL_PROVIDERS } from './setup/model-presets.js';
2
3
  import { pathToFileURL } from 'node:url';
3
4
  import { realpathSync } from 'node:fs';
4
5
  import { checkProject, checkExitCode, protectAcceptance } from './check/command.js';
@@ -104,7 +105,7 @@ export function main(argv) {
104
105
  switch (cmd) {
105
106
  case 'setup': {
106
107
  const targetDir = rest.find(a => !a.startsWith('-')) ?? '.';
107
- const valid = ['claude', 'codex', 'gemini'];
108
+ const valid = ['claude', 'codex', 'gemini', 'qwen'];
108
109
  const hostArg = rest.find(a => a.startsWith('--host='))?.slice('--host='.length);
109
110
  if (hostArg && !valid.includes(hostArg)) {
110
111
  console.error(`Invalid --host value: ${hostArg}`);
@@ -114,7 +115,7 @@ export function main(argv) {
114
115
  const agentTokens = agentArg === 'all' ? valid : agentArg?.split(',').map(a => a.trim());
115
116
  const invalidAgents = agentTokens?.filter(a => !valid.includes(a)) ?? [];
116
117
  if (agentArg && (invalidAgents.length > 0 || agentTokens?.length === 0)) {
117
- console.error(`Invalid --agent value: ${agentArg} (expected claude,codex,gemini|all)`);
118
+ console.error(`Invalid --agent value: ${agentArg} (expected claude,codex,gemini,qwen|all)`);
118
119
  return 1;
119
120
  }
120
121
  const agents = agentTokens ? [...new Set(agentTokens)] : undefined;
@@ -140,7 +141,14 @@ export function main(argv) {
140
141
  console.error('Invalid routing strategy');
141
142
  return 1;
142
143
  }
144
+ const modelProviderArg = rest.find(a => a.startsWith('--model-provider='))?.slice('--model-provider='.length);
145
+ const modelProviders = modelProviderArg?.split(',').map(value => value.trim());
146
+ if (modelProviders?.some(value => !MODEL_PROVIDERS.includes(value))) {
147
+ console.error('Invalid --model-provider (expected deepseek,kimi)');
148
+ return 1;
149
+ }
143
150
  return runSetup(targetDir, {
151
+ modelProviders: modelProviders,
144
152
  host: hostArg, agents, runner: runnerArg,
145
153
  codeGraph: graphArg,
146
154
  loop, routing, decisionPolicy: policyArg,
@@ -226,7 +234,7 @@ export function main(argv) {
226
234
  }
227
235
  if (sub === 'run' || sub === 'resume') {
228
236
  const provider = value('runner') ?? 'codex';
229
- if (!['codex', 'claude', 'gemini'].includes(provider))
237
+ if (!['codex', 'claude', 'gemini', 'qwen'].includes(provider))
230
238
  throw new Error('Unknown runner');
231
239
  return runProjectGoal(targetDir, { provider: provider, selection: { model: value('model') } }).then(goal => {
232
240
  console.log(JSON.stringify(goal));
@@ -269,7 +277,7 @@ export function main(argv) {
269
277
  const targetDir = rest.find(a => !a.startsWith('-')) ?? '.';
270
278
  const loop = rest.includes('--loop');
271
279
  const agentArg = rest.find(a => a.startsWith('--agent='))?.slice('--agent='.length);
272
- const all = ['claude', 'codex', 'gemini'];
280
+ const all = ['claude', 'codex', 'gemini', 'qwen'];
273
281
  const agents = !agentArg || agentArg === 'all'
274
282
  ? (agentArg === 'all' ? all : undefined)
275
283
  : agentArg.split(',').filter((a) => all.includes(a));
@@ -468,10 +476,10 @@ export function main(argv) {
468
476
  return 1;
469
477
  }
470
478
  const runnerArg = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
471
- const valid = ['claude', 'codex', 'gemini'];
479
+ const valid = ['claude', 'codex', 'gemini', 'qwen'];
472
480
  const agent = runnerArg && valid.includes(runnerArg) ? runnerArg : undefined;
473
481
  if (runnerArg && !agent) {
474
- console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini)`);
482
+ console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini|qwen)`);
475
483
  return 1;
476
484
  }
477
485
  const isolate = rest.includes('--isolate') ? true : rest.includes('--no-isolate') ? false : undefined;
@@ -479,7 +487,7 @@ export function main(argv) {
479
487
  let reviewer;
480
488
  if (reviewerArg) {
481
489
  if (!valid.includes(reviewerArg)) {
482
- console.error(`Invalid --reviewer value: ${reviewerArg} (expected claude|codex|gemini)`);
490
+ console.error(`Invalid --reviewer value: ${reviewerArg} (expected claude|codex|gemini|qwen)`);
483
491
  return 1;
484
492
  }
485
493
  reviewer = reviewerArg;
@@ -522,19 +530,19 @@ export function main(argv) {
522
530
  }
523
531
  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 });
524
532
  }
525
- 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=<claude|codex|gemini>] [--reviewer=<claude|codex|gemini>] [--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]');
533
+ 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=<claude|codex|gemini|qwen>] [--reviewer=<claude|codex|gemini|qwen>] [--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]');
526
534
  return 1;
527
535
  }
528
536
  case 'new': {
529
537
  const dir = rest.find(a => !a.startsWith('-'));
530
538
  if (!dir) {
531
- console.error('usage: yoke new <dir> [--idea="..."] [--agent=claude,codex,gemini|all] [--runner=<claude|codex|gemini>] [--loop]');
539
+ console.error('usage: yoke new <dir> [--idea="..."] [--agent=claude,codex,gemini,qwen|all] [--runner=<claude|codex|gemini|qwen>] [--loop]');
532
540
  return 1;
533
541
  }
534
542
  const idea = rest.find(a => a.startsWith('--idea='))?.slice('--idea='.length);
535
543
  const loop = rest.includes('--loop');
536
544
  const agentArg = rest.find(a => a.startsWith('--agent='))?.slice('--agent='.length);
537
- const all = ['claude', 'codex', 'gemini'];
545
+ const all = ['claude', 'codex', 'gemini', 'qwen'];
538
546
  const agents = !agentArg || agentArg === 'all'
539
547
  ? (agentArg === 'all' ? all : undefined)
540
548
  : agentArg.split(',').filter((a) => all.includes(a));
@@ -543,7 +551,7 @@ export function main(argv) {
543
551
  }
544
552
  const runnerArg = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
545
553
  if (runnerArg && !all.includes(runnerArg)) {
546
- console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini)`);
554
+ console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini|qwen)`);
547
555
  return 1;
548
556
  }
549
557
  return runNew(dir, { idea, agents, runner: runnerArg, loop });
@@ -553,7 +561,7 @@ export function main(argv) {
553
561
  const targetDir = rest.slice(1).find(a => !a.startsWith('-')) ?? '.';
554
562
  if (sub === 'assess') {
555
563
  const runner = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
556
- if (runner && !['codex', 'claude', 'gemini'].includes(runner)) {
564
+ if (runner && !['codex', 'claude', 'gemini', 'qwen'].includes(runner)) {
557
565
  console.error('Invalid planning runner');
558
566
  return 1;
559
567
  }
@@ -562,13 +570,13 @@ export function main(argv) {
562
570
  if (sub === 'draft') {
563
571
  const idea = rest.find(a => a.startsWith('--idea='))?.slice('--idea='.length);
564
572
  if (!idea) {
565
- console.error('usage: yoke prd draft [dir] --idea="..." [--runner=<claude|codex|gemini>] [--force] [--timeout=<minutes>]');
573
+ console.error('usage: yoke prd draft [dir] --idea="..." [--runner=<claude|codex|gemini|qwen>] [--force] [--timeout=<minutes>]');
566
574
  return 1;
567
575
  }
568
- const valid = ['claude', 'codex', 'gemini'];
576
+ const valid = ['claude', 'codex', 'gemini', 'qwen'];
569
577
  const runnerArg = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
570
578
  if (runnerArg && !valid.includes(runnerArg)) {
571
- console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini)`);
579
+ console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini|qwen)`);
572
580
  return 1;
573
581
  }
574
582
  const force = rest.includes('--force');
@@ -586,7 +594,7 @@ export function main(argv) {
586
594
  }
587
595
  if (sub === 'check')
588
596
  return runPrdCheck(targetDir);
589
- console.log('usage: yoke prd <draft|check|assess> [dir] [--idea="..."] [--runner=<claude|codex|gemini>] [--story=<id>] [--reassess] [--force] [--timeout=<minutes>]');
597
+ console.log('usage: yoke prd <draft|check|assess> [dir] [--idea="..."] [--runner=<claude|codex|gemini|qwen>] [--story=<id>] [--reassess] [--force] [--timeout=<minutes>]');
590
598
  return 1;
591
599
  }
592
600
  case 'context': {
@@ -601,10 +609,10 @@ export function main(argv) {
601
609
  }
602
610
  case 'review': {
603
611
  const targetDir = rest.find(a => !a.startsWith('-')) ?? '.';
604
- const valid = ['claude', 'codex', 'gemini'];
612
+ const valid = ['claude', 'codex', 'gemini', 'qwen'];
605
613
  const reviewerArg = rest.find(a => a.startsWith('--reviewer='))?.slice('--reviewer='.length);
606
614
  if (reviewerArg && !valid.includes(reviewerArg)) {
607
- console.error(`Invalid --reviewer value: ${reviewerArg} (expected claude|codex|gemini)`);
615
+ console.error(`Invalid --reviewer value: ${reviewerArg} (expected claude|codex|gemini|qwen)`);
608
616
  return 1;
609
617
  }
610
618
  const base = rest.find(a => a.startsWith('--base='))?.slice('--base='.length);
@@ -651,7 +659,7 @@ export function main(argv) {
651
659
  return runUpgrade();
652
660
  default:
653
661
  console.log('Project workflows: yoke check [dir] [--json|--protect] | goal set|run|resume|pause|status|handoff|budget [dir] | projects add|list|remove | dashboard [dir] [--port=N]');
654
- console.log('usage: yoke <setup [dir] | new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=claude,codex,gemini|all] [--code-graph=graphify|serena] [--loop] | change <add|status> [dir] | prd <draft|check|assess> [dir] | loop <on|off|status|decision|answer|resume|run|cleanup> | context <init|status> | review [dir] [--reviewer=<claude|codex|gemini>] [--base=<ref>] [--focus="..."] | design-scan [dir] [--max=N] [--report] | flow-smoke [dir] [--url=<baseUrl>] [--label=<name>] | upgrade>');
662
+ console.log('usage: yoke <setup [dir] | new <dir> [--idea="..."] | validate [canonDir] | retrofit [targetDir] [--agent=claude,codex,gemini,qwen|all] [--code-graph=graphify|serena] [--loop] | change <add|status> [dir] | prd <draft|check|assess> [dir] | loop <on|off|status|decision|answer|resume|run|cleanup> | context <init|status> | review [dir] [--reviewer=<claude|codex|gemini|qwen>] [--base=<ref>] [--focus="..."] | design-scan [dir] [--max=N] [--report] | flow-smoke [dir] [--url=<baseUrl>] [--label=<name>] | upgrade>');
655
663
  return cmd ? 1 : 0;
656
664
  }
657
665
  }
@@ -12,8 +12,8 @@ import { killProcessTreeForCleanup } from '../loop/watchdog.js';
12
12
  import { buildProviderInvocation, startProviderProcess } from '../agents/providers.js';
13
13
  import { appendEvent } from '../observability/events.js';
14
14
  import { statePath } from '../workspace/state.js';
15
- const Attempt = z.object({ provider: z.enum(['codex', 'claude', 'gemini']), model: z.string().optional(), startedAt: z.string(), durationMs: z.number().nonnegative(), success: z.boolean(), summary: z.string(), checkId: z.string(), inputTokens: z.number().nonnegative().optional(), outputTokens: z.number().nonnegative().optional() });
16
- const Goal = z.object({ version: z.literal(1), id: z.string().uuid(), objective: z.string().trim().min(1).max(16000), status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), createdAt: z.string(), updatedAt: z.string(), maxAttempts: z.number().int().min(1).max(20), maxMinutes: z.number().positive().max(1440), tokenBudget: z.number().int().positive().optional(), attempts: z.array(Attempt), reason: z.string().optional(), lastCheck: z.string().optional(), pendingAttempt: z.object({ provider: z.enum(['codex', 'claude', 'gemini']), model: z.string().optional(), startedAt: z.string().datetime() }).optional() }).strict();
15
+ const Attempt = z.object({ provider: z.enum(['codex', 'claude', 'gemini', 'qwen']), model: z.string().optional(), startedAt: z.string(), durationMs: z.number().nonnegative(), success: z.boolean(), summary: z.string(), checkId: z.string(), inputTokens: z.number().nonnegative().optional(), outputTokens: z.number().nonnegative().optional() });
16
+ const Goal = z.object({ version: z.literal(1), id: z.string().uuid(), objective: z.string().trim().min(1).max(16000), status: z.enum(['active', 'running', 'paused', 'blocked', 'complete']), createdAt: z.string(), updatedAt: z.string(), maxAttempts: z.number().int().min(1).max(20), maxMinutes: z.number().positive().max(1440), tokenBudget: z.number().int().positive().optional(), attempts: z.array(Attempt), reason: z.string().optional(), lastCheck: z.string().optional(), pendingAttempt: z.object({ provider: z.enum(['codex', 'claude', 'gemini', 'qwen']), model: z.string().optional(), startedAt: z.string().datetime() }).optional() }).strict();
17
17
  const goalPath = (root) => statePath(root, 'goal.json');
18
18
  function save(root, goal) {
19
19
  const parsed = Goal.parse({ ...goal, updatedAt: new Date().toISOString() });
@@ -18,7 +18,7 @@ const RichClaimSchema = LegacyClaimSchema.extend({
18
18
  heartbeatAt: z.string().min(1),
19
19
  baseCommit: z.string().min(1).optional(),
20
20
  worktree: z.string().min(1).optional(),
21
- provider: z.enum(['claude', 'codex', 'gemini']).optional(),
21
+ provider: z.enum(['claude', 'codex', 'gemini', 'qwen']).optional(),
22
22
  model: z.string().min(1).optional(),
23
23
  role: z.enum(['implementation', 'quality', 'repair', 'review']).optional(),
24
24
  cancellation: z.object({
@@ -60,9 +60,9 @@ export const DecisionResumeSchema = z.object({
60
60
  answered: z.boolean().optional(),
61
61
  /** Absent preserves the default unlimited run across a critical-decision resume. */
62
62
  maxIterations: z.number().int().positive().optional(),
63
- agent: z.enum(['claude', 'codex', 'gemini']).optional(),
63
+ agent: z.enum(['claude', 'codex', 'gemini', 'qwen']).optional(),
64
64
  isolate: z.boolean().optional(),
65
- reviewer: z.enum(['claude', 'codex', 'gemini']).optional(),
65
+ reviewer: z.enum(['claude', 'codex', 'gemini', 'qwen']).optional(),
66
66
  review: z.boolean().optional(),
67
67
  allowSelfReview: z.boolean().optional(),
68
68
  timeoutMinutes: z.number().nonnegative().optional(),
package/dist/loop/prd.js CHANGED
@@ -42,7 +42,7 @@ export const StorySchema = z.object({
42
42
  area: z.string().min(1).optional(),
43
43
  /** Advisory relative file/directory scopes; not a filesystem permission boundary. */
44
44
  writes: z.array(z.string().min(1).max(500).refine(validWriteScope, 'Write scopes must be relative non-glob paths without traversal')).max(100).optional(),
45
- agent: z.enum(['claude', 'codex', 'gemini']).optional(),
45
+ agent: z.enum(['claude', 'codex', 'gemini', 'qwen']).optional(),
46
46
  /** Inbox request that created this story. Used for idempotent append-only intake. */
47
47
  sourceChange: z.string().min(1).optional(),
48
48
  quality: StoryQualityDeclarationSchema.optional(),
@@ -379,7 +379,7 @@ export function runLoopCommand(targetDir, opts) {
379
379
  let review = opts.reviewRunner;
380
380
  let reviewProvider = 'unknown';
381
381
  if (!review && (opts.review || opts.reviewer)) {
382
- const reviewerAgent = opts.reviewer ?? ['codex', 'gemini', 'claude'].find(agent => agent !== runnerAgent && available(agent));
382
+ const reviewerAgent = opts.reviewer ?? ['codex', 'gemini', 'qwen', 'claude'].find(agent => agent !== runnerAgent && available(agent));
383
383
  if (!reviewerAgent) {
384
384
  if (!opts.allowSelfReview) {
385
385
  console.error('No independent reviewer CLI is available. Install or select a second agent, or pass --allow-self-review explicitly.');
@@ -393,13 +393,13 @@ export function runLoopCommand(targetDir, opts) {
393
393
  return 2;
394
394
  }
395
395
  if (!available(resolvedReviewer)) {
396
- console.error(`Reviewer agent CLI "${resolvedReviewer}" was not found on PATH. Install it, or pick another with --reviewer=<claude|codex|gemini>.`);
396
+ console.error(`Reviewer agent CLI "${resolvedReviewer}" was not found on PATH. Install it, or pick another with --reviewer=<claude|codex|gemini|qwen>.`);
397
397
  return 2;
398
398
  }
399
399
  review = context => {
400
400
  const implementer = readStatus(targetDir)?.routingDecisions?.[context.story.id]?.provider ?? runnerAgent;
401
401
  const selectedReviewer = !opts.reviewer && resolvedReviewer === implementer
402
- ? ["codex", "claude", "gemini"].find(agent => agent !== implementer && available(agent)) ?? resolvedReviewer : resolvedReviewer;
402
+ ? ["codex", "claude", "gemini", "qwen"].find(agent => agent !== implementer && available(agent)) ?? resolvedReviewer : resolvedReviewer;
403
403
  if (selectedReviewer === implementer && !opts.allowSelfReview)
404
404
  return { success: false, summary: "Independent review requires a provider distinct from the routed implementer", reviewOutcome: { kind: "infrastructure", summary: "Routed implementation and reviewer share a provider" } };
405
405
  reviewProvider = selectedReviewer;
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  const QualityPolicySchema = z.enum(['blocking', 'advisory']);
3
- const QualityAgentSchema = z.enum(['claude', 'codex', 'gemini']);
3
+ const QualityAgentSchema = z.enum(['claude', 'codex', 'gemini', 'qwen']);
4
4
  const RelativePathSchema = z.string().min(1).refine(value => !/^(?:[A-Za-z]:[\\/]|[\\/])/.test(value) && !value.split(/[\\/]+/).includes('..'), 'path must stay within the project');
5
5
  const QualityReferenceSchema = z.object({
6
6
  name: z.string().min(1),
@@ -1,5 +1,6 @@
1
1
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
+ import { mergeQwenSettings } from './qwen-settings.js';
3
4
  import { mergeJson } from './merge-json.js';
4
5
  import { carryPreserved } from './preserve.js';
5
6
  export function applyActions(actions, targetDir, opts) {
@@ -28,7 +29,7 @@ export function applyActions(actions, targetDir, opts) {
28
29
  catch {
29
30
  throw new Error(`yoke: cannot merge ${action.target} — existing file is not valid JSON. Fix or delete it and re-run.`);
30
31
  }
31
- const merged = JSON.stringify(mergeJson(parsedCurrent, JSON.parse(action.content)), null, 2) + '\n';
32
+ const merged = JSON.stringify((action.target === '.qwen/settings.json' ? mergeQwenSettings : mergeJson)(parsedCurrent, JSON.parse(action.content)), null, 2) + '\n';
32
33
  if (merged === current) {
33
34
  results.push({ target: action.target, status: 'unchanged', reason: action.reason });
34
35
  continue;
@@ -43,6 +44,12 @@ export function applyActions(actions, targetDir, opts) {
43
44
  }
44
45
  if (typeof action.content === 'string') {
45
46
  const current = currentBytes.toString('utf8');
47
+ // Exact matches need no preservation pass. In particular, skill examples
48
+ // may contain literal preserve markers which must not be reinterpreted.
49
+ if (current === action.content) {
50
+ results.push({ target: action.target, status: 'unchanged', reason: action.reason });
51
+ continue;
52
+ }
46
53
  // Carry user content marked with yoke preserve markers into the new file.
47
54
  content = carryPreserved(current, action.content);
48
55
  if (content !== action.content)
@@ -80,7 +80,7 @@ export const YokeConfigSchema = z.object({
80
80
  model: z.string().min(1).optional(),
81
81
  reasoningEffort: z.string().min(1).optional(),
82
82
  }).optional(),
83
- workers: z.array(RoutingWorkerSchema).max(12).default([]),
83
+ workers: z.array(RoutingWorkerSchema).max(32).default([]),
84
84
  rules: z.array(RoutingRuleSchema).max(100).optional(),
85
85
  }).optional(),
86
86
  commit: z.object({
@@ -10,6 +10,8 @@ export function detectProject(targetDir) {
10
10
  agents.push('codex');
11
11
  if (has('.gemini') || has('GEMINI.md'))
12
12
  agents.push('gemini');
13
+ if (has('.qwen') || has('QWEN.md'))
14
+ agents.push('qwen');
13
15
  return {
14
16
  agents,
15
17
  hasAgentsMd: has('AGENTS.md'),
@@ -1,6 +1,7 @@
1
1
  import { planClaude } from './planners/claude.js';
2
2
  import { planCodex } from './planners/codex.js';
3
3
  import { planGemini } from './planners/gemini.js';
4
+ import { planQwen } from './planners/qwen.js';
4
5
  import { baseContextActions } from './context-actions.js';
5
6
  export function planClaudeRetrofit(canonDir, targetDir) {
6
7
  return planClaude(canonDir, targetDir);
@@ -9,6 +10,7 @@ export const PLANNERS = {
9
10
  claude: (c, t, cg) => planClaude(c, t, undefined, cg),
10
11
  codex: planCodex,
11
12
  gemini: planGemini,
13
+ qwen: planQwen,
12
14
  };
13
15
  export function planRetrofit(canonDir, targetDir, agents, codeGraph = 'graphify') {
14
16
  const seen = new Set();
@@ -0,0 +1,73 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { loadManifest } from '../../canon/manifest.js';
4
+ import { parseFrontmatter } from '../../canon/frontmatter.js';
5
+ import { mcpServers, rtkInstruction } from '../tools.js';
6
+ import { PRESERVE_SCAFFOLD } from '../preserve.js';
7
+ import { skillPackageActions } from '../skill-actions.js';
8
+ function tomlString(s) {
9
+ return '"""\n' + s.replace(/\\/g, '\\\\').replace(/"""/g, '\\"\\"\\"') + '\n"""';
10
+ }
11
+ export function planQwen(canonDir, _targetDir, codeGraph = 'graphify') {
12
+ const manifest = loadManifest(join(canonDir, 'manifest.yaml'));
13
+ const actions = [];
14
+ // QWEN.md: baseline + instruction for commands outside the rewrite hook.
15
+ const baseline = readFileSync(join(canonDir, 'AGENTS.md'), 'utf8');
16
+ const autoSkillIndex = manifest.skills
17
+ .filter(skill => skill.invocation === 'auto')
18
+ .map(skill => {
19
+ const source = readFileSync(join(canonDir, skill.path, 'SKILL.md'), 'utf8');
20
+ const description = String(parseFrontmatter(source)?.description ?? skill.id).replace(/\s+/gu, ' ').trim();
21
+ return `- \`/${skill.id}\` — ${description}`;
22
+ })
23
+ .join('\n');
24
+ actions.push({
25
+ kind: 'write',
26
+ target: 'QWEN.md',
27
+ content: `${baseline}\n${rtkInstruction()}\n\n## Yoke automatic skills\n\nUse the matching command when its capability is relevant:\n\n${autoSkillIndex}\n\n${PRESERVE_SCAFFOLD}\n`,
28
+ reason: 'baseline + rtk instruction',
29
+ });
30
+ // One TOML slash command per skill.
31
+ for (const skill of manifest.skills) {
32
+ const body = readFileSync(join(canonDir, skill.path, 'SKILL.md'), 'utf8');
33
+ const fm = parseFrontmatter(body) ?? {};
34
+ // Collapse any newlines so the single-line TOML `description = "..."` stays valid
35
+ // even for ported skills whose frontmatter description spans multiple lines.
36
+ const description = String(fm.description ?? skill.id).replace(/\s*\r?\n\s*/g, ' ').trim();
37
+ const skillBody = body.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim();
38
+ const prompt = `You are using the "${skill.id}" skill.\n\n${skillBody}\n\nFollow it for the current task.`;
39
+ actions.push({
40
+ kind: 'write',
41
+ target: `.qwen/commands/${skill.id}.toml`,
42
+ content: `description = "${description.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"\nprompt = ${tomlString(prompt)}\n`,
43
+ reason: `qwen command: ${skill.id}`,
44
+ });
45
+ actions.push(...skillPackageActions(canonDir, skill, 'qwen'));
46
+ }
47
+ actions.push({
48
+ kind: 'write',
49
+ target: '.qwen/hooks/qwen-rtk-hook.mjs',
50
+ content: readFileSync(join(canonDir, 'tools/qwen-rtk-hook.mjs'), 'utf8'),
51
+ reason: 'portable RTK PreToolUse retry guard',
52
+ });
53
+ // Merge to preserve user MCP servers, context and unrelated hooks.
54
+ actions.push({
55
+ kind: 'write',
56
+ target: '.qwen/settings.json',
57
+ merge: true,
58
+ content: JSON.stringify({
59
+ mcpServers: mcpServers(codeGraph),
60
+ context: { fileName: ['AGENTS.md', 'QWEN.md'] },
61
+ hooks: { PreToolUse: [{ matcher: '^run_shell_command$', hooks: [{ name: 'yoke-rtk', type: 'command', command: 'node .qwen/hooks/qwen-rtk-hook.mjs' }] }] },
62
+ }, null, 2) + '\n',
63
+ reason: 'MCP servers + AGENTS.md context',
64
+ });
65
+ // Also ship AGENTS.md so the context.fileName entry resolves.
66
+ actions.push({
67
+ kind: 'write',
68
+ target: 'AGENTS.md',
69
+ content: baseline,
70
+ reason: 'baseline instructions (shared)',
71
+ });
72
+ return actions;
73
+ }
@@ -0,0 +1,17 @@
1
+ import { mergeJson } from './merge-json.js';
2
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
3
+ /** Remove only Yoke 1.11's inert Gemini-shaped hook, preserving unrelated hooks. */
4
+ export function mergeQwenSettings(current, incoming) {
5
+ if (!record(current) || !record(current.hooks) || !Array.isArray(current.hooks.BeforeTool))
6
+ return mergeJson(current, incoming);
7
+ const beforeTool = current.hooks.BeforeTool.flatMap(group => {
8
+ if (!record(group) || group.matcher !== '^run_shell_command$' || !Array.isArray(group.hooks))
9
+ return [group];
10
+ const hooks = group.hooks.filter(hook => !(record(hook) && hook.name === 'yoke-rtk' && hook.type === 'command' && hook.command === 'node .qwen/hooks/qwen-rtk-hook.mjs'));
11
+ return hooks.length ? [{ ...group, hooks }] : [];
12
+ });
13
+ const hooks = { ...current.hooks, BeforeTool: beforeTool };
14
+ if (!beforeTool.length)
15
+ delete hooks.BeforeTool;
16
+ return mergeJson({ ...current, hooks }, incoming);
17
+ }
@@ -4,6 +4,7 @@ const roots = {
4
4
  claude: '.claude/skills',
5
5
  codex: '.agents/skills',
6
6
  gemini: '.gemini/skills',
7
+ qwen: '.qwen/skills',
7
8
  };
8
9
  function manualClaudeSkill(content, skill) {
9
10
  const source = content.toString('utf8');
@@ -48,7 +49,7 @@ export function skillPackageActions(canonDir, skill, provider) {
48
49
  .map(file => ({
49
50
  kind: 'write',
50
51
  target: `${roots[provider]}/${skill.id}/${file.relativePath}`,
51
- content: provider === 'claude' && skill.invocation === 'manual' && file.relativePath === 'SKILL.md'
52
+ content: (provider === 'claude' || provider === 'qwen') && skill.invocation === 'manual' && file.relativePath === 'SKILL.md'
52
53
  ? manualClaudeSkill(file.content, skill)
53
54
  : portableContent(file),
54
55
  executable: file.executable,
@@ -5,7 +5,7 @@ import { loadConfig } from '../retrofit/config.js';
5
5
  import { parseReviewVerdict } from './verdict.js';
6
6
  // Resolve to the first available agent, preferring a *second* model so the review
7
7
  // is genuinely cross-model. claude last => a Claude-only box degrades to self-review.
8
- const RESOLUTION_ORDER = ['codex', 'gemini', 'claude'];
8
+ const RESOLUTION_ORDER = ['codex', 'gemini', 'qwen', 'claude'];
9
9
  export function runReview(targetDir, opts = {}) {
10
10
  const available = opts.isAvailable ?? isAgentAvailable;
11
11
  const implementer = opts.implementer ?? loadConfig(targetDir)?.agents[0] ?? 'claude';
@@ -68,7 +68,7 @@ export function chooseCapability(input) {
68
68
  const worker = reliable[0];
69
69
  const blocked = !worker && (input.fallback === 'block' || input.maxTier !== undefined);
70
70
  const provider = worker?.agent ?? input.story.agent ?? input.parent;
71
- const selection = worker ? { model: worker.model, reasoningEffort: worker.reasoningEffort, nativeMultiAgent: false, ...(provider !== 'gemini' && input.parentSelection?.bare !== undefined ? { bare: input.parentSelection.bare } : {}) }
71
+ const selection = worker ? { model: worker.model, reasoningEffort: worker.reasoningEffort, nativeMultiAgent: false, ...(provider !== 'gemini' && provider !== 'qwen' && input.parentSelection?.bare !== undefined ? { bare: input.parentSelection.bare } : {}) }
72
72
  : { ...(provider === input.parent ? input.parentSelection : {}), nativeMultiAgent: false };
73
73
  const reason = `${role}: ${tiers[level]}; ${input.assessment.reason}${failures.length ? `; ${failures.length} verified failure(s), ${failures.length === 1 ? 'one targeted repair' : 'escalated'}` : ''}${worker ? '' : '; no eligible profile, parent/provider fallback'}`;
74
74
  return { worker, provider, selection, reason: blocked ? `${role}: no eligible profile within routing limits; execution blocked` : reason, blocked, requiredTier: baseTier, selectedTier: tiers[level], failures: failures.length, exhausted, next: input.maxTier && level >= tiers.indexOf(input.maxTier) ? 'stop at configured tier limit' : level < 3 ? tiers[level + 1] : 'stop after bounded attempts' };
@@ -247,7 +247,7 @@ function routingSteps(options) {
247
247
  return blocked('Selected routing profile exceeds configured limits; execution blocked');
248
248
  const provider = worker?.agent ?? options.parent;
249
249
  const selection = worker
250
- ? { model: worker.model, reasoningEffort: worker.reasoningEffort, nativeMultiAgent: false, ...(provider !== 'gemini' ? { bare: options.parentSelection?.bare } : {}) }
250
+ ? { model: worker.model, reasoningEffort: worker.reasoningEffort, nativeMultiAgent: false, ...(provider !== 'gemini' && provider !== 'qwen' ? { bare: options.parentSelection?.bare } : {}) }
251
251
  : { ...(options.parentSelection ?? {}), nativeMultiAgent: false };
252
252
  const workerStarted = now();
253
253
  const result = yield () => makeWorker(provider, selection)(ctx);
@@ -1,10 +1,13 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
2
  import { stdin as input, stdout as output } from 'node:process';
3
3
  import { detectHostAgent } from '../agents/host.js';
4
- import { loadConfig, saveConfig } from '../retrofit/config.js';
4
+ import { loadConfig, saveConfig, YokeConfigSchema } from '../retrofit/config.js';
5
5
  import { detectProject } from '../retrofit/detect.js';
6
+ import { applyActions } from '../retrofit/apply.js';
7
+ import { join } from 'node:path';
8
+ import { modelPresetWorkers, planModelPresets } from './model-presets.js';
6
9
  import { runRetrofit } from '../retrofit/command.js';
7
- const ALL_AGENTS = ['claude', 'codex', 'gemini'];
10
+ const ALL_AGENTS = ['claude', 'codex', 'gemini', 'qwen'];
8
11
  export function defaultRoutingWorkers(agents) {
9
12
  const workers = {
10
13
  claude: [
@@ -25,6 +28,10 @@ export function defaultRoutingWorkers(agents) {
25
28
  { id: 'gemini-strong', agent: 'gemini', model: 'gemini-2.5-pro', tier: 'strong', costTier: 'medium', capabilities: ['debugging'] },
26
29
  { id: 'gemini-frontier', agent: 'gemini', model: 'gemini-2.5-pro', tier: 'frontier', costTier: 'high', capabilities: ['architecture'] },
27
30
  ],
31
+ qwen: [
32
+ // Respect the user's Qwen Code account/model. API presets are opt-in.
33
+ { id: 'qwen-standard', agent: 'qwen', tier: 'standard', costTier: 'medium', capabilities: ['implementation'] },
34
+ ],
28
35
  };
29
36
  return agents.flatMap(agent => workers[agent]);
30
37
  }
@@ -44,6 +51,9 @@ function yes(value, fallback) {
44
51
  }
45
52
  export async function runSetup(targetDir, opts = {}) {
46
53
  const existing = loadConfig(targetDir);
54
+ const modelProviders = opts.modelProviders ?? [];
55
+ const presetActions = planModelPresets(targetDir, modelProviders);
56
+ const presetWorkers = modelPresetWorkers(modelProviders);
47
57
  const detected = detectProject(targetDir);
48
58
  const host = opts.host ?? detectHostAgent();
49
59
  const configuredAgents = existing?.agents.filter(a => ALL_AGENTS.includes(a)) ?? [];
@@ -53,7 +63,7 @@ export async function runSetup(targetDir, opts = {}) {
53
63
  ? configuredAgents
54
64
  : detected.agents.length > 0
55
65
  ? detected.agents
56
- : [host ?? 'claude'];
66
+ : modelProviders.length ? ['qwen'] : [host ?? 'claude'];
57
67
  const defaultGraph = opts.codeGraph ?? existing?.codeGraph ?? 'graphify';
58
68
  const defaultLoop = opts.loop ?? existing?.loop.enabled ?? true;
59
69
  const defaultRunner = opts.runner ?? existing?.runner?.agent ?? (host && defaultAgents.includes(host) ? host : defaultAgents[0] ?? host ?? 'claude');
@@ -75,12 +85,12 @@ export async function runSetup(targetDir, opts = {}) {
75
85
  let decisionPolicy = defaultPolicy;
76
86
  let routing = defaultRouting;
77
87
  if (interactive && ask) {
78
- agents = parseAgents(await ask(`Agents [${defaultAgents.join(',')}] (claude,codex,gemini|all): `), defaultAgents);
88
+ agents = parseAgents(await ask(`Agents [${defaultAgents.join(',')}] (claude,codex,gemini,qwen|all): `), defaultAgents);
79
89
  const graphAnswer = (await ask(`Code graph [${defaultGraph}] (graphify|serena): `)).trim().toLowerCase();
80
90
  if (graphAnswer === 'graphify' || graphAnswer === 'serena')
81
91
  codeGraph = graphAnswer;
82
92
  loop = yes(await ask(`Enable autonomous loop? [${defaultLoop ? 'yes' : 'no'}]: `), defaultLoop);
83
- const runnerAnswer = (await ask(`Default runner [${runner}] (claude|codex|gemini): `)).trim().toLowerCase();
93
+ const runnerAnswer = (await ask(`Default runner [${runner}] (claude|codex|gemini|qwen): `)).trim().toLowerCase();
84
94
  if (ALL_AGENTS.includes(runnerAnswer))
85
95
  runner = runnerAnswer;
86
96
  const policyAnswer = (await ask(`Decision mode [${decisionPolicy}] (auto|critical): `)).trim().toLowerCase();
@@ -88,17 +98,26 @@ export async function runSetup(targetDir, opts = {}) {
88
98
  decisionPolicy = policyAnswer;
89
99
  routing = yes(await ask(`Enable adaptive multi-model routing? [${defaultRouting ? 'yes' : 'no'}]: `), defaultRouting);
90
100
  }
101
+ if (modelProviders.length && !agents.includes('qwen'))
102
+ agents = [...agents, 'qwen'];
91
103
  if (!agents.includes(runner))
92
104
  agents = [...agents, runner];
93
105
  const code = runRetrofit(targetDir, { loop, agents, codeGraph, host });
94
106
  if (code !== 0)
95
107
  return code;
108
+ applyActions(presetActions, targetDir, { backupDir: join(targetDir, '.yoke', 'backups', `model-presets-${Date.now()}`) });
96
109
  const config = loadConfig(targetDir);
97
110
  if (!config)
98
111
  return 1;
99
112
  config.loop = { parallel: 'auto', isolate: true, ...config.loop, enabled: loop, decisionPolicy };
100
- config.runner = { ...config.runner, agent: runner };
113
+ const priorRunner = existing?.runner?.agent ?? existing?.agents[0];
114
+ const selectPresetModel = presetWorkers.length > 0 && runner === 'qwen' && (!existing || (priorRunner !== undefined && priorRunner !== runner));
115
+ if (selectPresetModel && priorRunner !== 'qwen')
116
+ config.runner = { permissions: config.runner?.permissions };
117
+ config.runner = { ...config.runner, agent: runner, ...(selectPresetModel && !config.runner?.model ? { model: presetWorkers[0].model } : {}) };
101
118
  const existingWorkers = config.routing?.workers ?? [];
119
+ const baseWorkers = existingWorkers.length > 0 && !opts.routingPreset ? existingWorkers : defaultRoutingWorkers(agents).filter(worker => !modelProviders.length || worker.agent !== 'qwen');
120
+ const workers = [...baseWorkers, ...presetWorkers.filter(worker => !baseWorkers.some(existing => existing.id === worker.id))];
102
121
  config.routing = {
103
122
  ...config.routing,
104
123
  enabled: routing,
@@ -107,8 +126,9 @@ export async function runSetup(targetDir, opts = {}) {
107
126
  assessmentPolicy: existing?.routing?.assessmentPolicy ?? (existing ? 'on-demand' : 'prepared'),
108
127
  fallback: existing?.routing?.fallback ?? (existing ? 'parent' : 'block'),
109
128
  ...(config.routing?.orchestrator ? { orchestrator: config.routing.orchestrator } : {}),
110
- workers: existingWorkers.length > 0 && !opts.routingPreset ? existingWorkers : defaultRoutingWorkers(agents),
129
+ workers,
111
130
  };
131
+ YokeConfigSchema.parse(config);
112
132
  saveConfig(targetDir, config);
113
133
  console.log(`Yoke setup complete: agents=${agents.join(',')} · runner=${runner} · loop=${loop ? 'on' : 'off'} · routing=${routing ? 'on' : 'off'} · decisions=${decisionPolicy}`);
114
134
  return 0;
@@ -0,0 +1,48 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ export const MODEL_PROVIDERS = ['deepseek', 'kimi'];
4
+ const PRESETS = {
5
+ deepseek: {
6
+ baseUrl: 'https://api.deepseek.com/v1', envKey: 'DEEPSEEK_API_KEY',
7
+ models: [
8
+ { id: 'deepseek-v4-flash', workerId: 'deepseek-standard', tier: 'standard', costTier: 'low', contextWindowSize: 1_000_000 },
9
+ { id: 'deepseek-v4-pro', workerId: 'deepseek-strong', tier: 'strong', costTier: 'medium', contextWindowSize: 1_000_000 },
10
+ ],
11
+ },
12
+ kimi: {
13
+ baseUrl: 'https://api.moonshot.ai/v1', envKey: 'MOONSHOT_API_KEY',
14
+ models: [
15
+ { id: 'kimi-k2.6', workerId: 'kimi-standard', tier: 'standard', costTier: 'medium', contextWindowSize: 262_144 },
16
+ { id: 'kimi-k2.7-code', workerId: 'kimi-strong', tier: 'strong', costTier: 'medium', contextWindowSize: 262_144 },
17
+ { id: 'kimi-k3', workerId: 'kimi-frontier', tier: 'frontier', costTier: 'high', contextWindowSize: 1_000_000 },
18
+ ],
19
+ },
20
+ };
21
+ export function modelPresetWorkers(providers) {
22
+ return [...new Set(providers)].flatMap(provider => PRESETS[provider].models.map(model => ({
23
+ id: model.workerId, agent: 'qwen', model: `openai::${model.id}`, tier: model.tier,
24
+ costTier: model.costTier, capabilities: ['implementation'],
25
+ })));
26
+ }
27
+ /** Explicit opt-in only. Keep user routes intact; new entries contain only environment key references. */
28
+ export function planModelPresets(targetDir, providers) {
29
+ if (!providers.length)
30
+ return [];
31
+ const file = join(targetDir, '.qwen/settings.json');
32
+ const settings = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
33
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings))
34
+ throw Error('Qwen settings must be an object');
35
+ if (settings.modelProviders !== undefined && (!settings.modelProviders || typeof settings.modelProviders !== 'object' || Array.isArray(settings.modelProviders)))
36
+ throw Error('Qwen modelProviders must be an object');
37
+ const existing = settings.modelProviders?.openai ?? [];
38
+ if (!Array.isArray(existing) || existing.some(model => !model || typeof model.id !== 'string'))
39
+ throw Error('Qwen modelProviders.openai must be an array of models with ids');
40
+ const entries = [...new Set(providers)].flatMap(provider => {
41
+ const preset = PRESETS[provider];
42
+ return preset.models.filter(model => !existing.some(entry => entry.id === model.id)).map(model => ({
43
+ id: model.id, baseUrl: preset.baseUrl, envKey: preset.envKey,
44
+ generationConfig: { contextWindowSize: model.contextWindowSize },
45
+ }));
46
+ });
47
+ return [{ kind: 'write', target: '.qwen/settings.json', merge: true, content: JSON.stringify({ modelProviders: { openai: entries } }, null, 2) + '\n', reason: 'explicit DeepSeek/Kimi API model presets (environment key references only)' }];
48
+ }