@hecer/yoke 1.9.0 → 1.11.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 (153) hide show
  1. package/.claude-plugin/plugin.json +13 -13
  2. package/.codex-plugin/plugin.json +7 -7
  3. package/CHANGELOG.md +398 -358
  4. package/README.md +915 -913
  5. package/TODOS.md +5 -5
  6. package/agents/docs.toml +6 -6
  7. package/agents/implementer.toml +6 -6
  8. package/agents/reviewer.toml +6 -6
  9. package/agents/security.toml +6 -6
  10. package/bench/README.md +86 -86
  11. package/bench/RESULTS.md +35 -35
  12. package/bench/output-compaction.mjs +65 -65
  13. package/bench/result-schema.mjs +12 -12
  14. package/bench/results/claude-2026-07-27T18-03-26.json +50 -50
  15. package/bench/results/codex-unavailable-1785175418318.json +15 -15
  16. package/bench/results/gemini-2026-07-27T18-03-44.json +46 -46
  17. package/bench/run-matrix.mjs +26 -26
  18. package/bench/run.mjs +106 -106
  19. package/canon/AGENTS.md +30 -30
  20. package/canon/context/DECISIONS.md +4 -4
  21. package/canon/context/GLOSSARY.md +11 -11
  22. package/canon/context/KNOWLEDGE.md +4 -4
  23. package/canon/context/PROJECT.md +15 -15
  24. package/canon/loop/loop-spec.md +65 -65
  25. package/canon/loop/prd.schema.md +46 -40
  26. package/canon/manifest.yaml +59 -59
  27. package/canon/policy/gates.md +7 -7
  28. package/canon/policy/roles.md +9 -9
  29. package/canon/skills/ATTRIBUTION.md +99 -99
  30. package/canon/skills/authoring-prd/SKILL.md +56 -56
  31. package/canon/skills/brainstorming/SKILL.md +164 -164
  32. package/canon/skills/codebase-design/DEEPENING.md +15 -15
  33. package/canon/skills/codebase-design/DESIGN-IT-TWICE.md +12 -12
  34. package/canon/skills/codebase-design/SKILL.md +39 -39
  35. package/canon/skills/dispatching-parallel-agents/SKILL.md +182 -182
  36. package/canon/skills/document-release/SKILL.md +302 -302
  37. package/canon/skills/domain-modeling/ADR-FORMAT.md +19 -19
  38. package/canon/skills/domain-modeling/CONTEXT-FORMAT.md +39 -39
  39. package/canon/skills/domain-modeling/SKILL.md +35 -35
  40. package/canon/skills/executing-plans/SKILL.md +70 -70
  41. package/canon/skills/finishing-a-development-branch/SKILL.md +200 -200
  42. package/canon/skills/health/SKILL.md +177 -177
  43. package/canon/skills/maintaining-context/SKILL.md +34 -34
  44. package/canon/skills/minimal-code/SKILL.md +21 -21
  45. package/canon/skills/no-ai-slop/SKILL.md +103 -103
  46. package/canon/skills/no-ai-slop/eval.md +43 -43
  47. package/canon/skills/plan-ceo-review/SKILL.md +541 -541
  48. package/canon/skills/plan-eng-review/SKILL.md +362 -362
  49. package/canon/skills/receiving-code-review/SKILL.md +213 -213
  50. package/canon/skills/requesting-code-review/SKILL.md +105 -105
  51. package/canon/skills/resolving-merge-conflicts/SKILL.md +18 -18
  52. package/canon/skills/retro/SKILL.md +397 -397
  53. package/canon/skills/review/SKILL.md +246 -246
  54. package/canon/skills/ship/SKILL.md +691 -691
  55. package/canon/skills/subagent-driven-development/SKILL.md +277 -277
  56. package/canon/skills/systematic-debugging/SKILL.md +296 -296
  57. package/canon/skills/tdd/SKILL.md +371 -371
  58. package/canon/skills/unslop-ui/SKILL.md +34 -34
  59. package/canon/skills/using-git-worktrees/SKILL.md +218 -218
  60. package/canon/skills/verification-before-completion/SKILL.md +139 -139
  61. package/canon/skills/visual-verification/SKILL.md +54 -54
  62. package/canon/skills/workflow/SKILL.md +22 -22
  63. package/canon/skills/writing-for-agents/SKILL-MECHANICS.md +27 -27
  64. package/canon/skills/writing-for-agents/SKILL.md +42 -42
  65. package/canon/skills/writing-plans/SKILL.md +152 -152
  66. package/canon/skills/writing-skills/SKILL.md +655 -655
  67. package/canon/skills/yoke-retrofit/SKILL.md +26 -26
  68. package/canon/skills/yoke-workflow/SKILL.md +20 -20
  69. package/canon/tools/codex-rtk-hook.mjs +35 -35
  70. package/canon/tools/gemini-rtk-hook.mjs +25 -25
  71. package/canon/tools/graphify.md +3 -3
  72. package/canon/tools/playwright-mcp.md +3 -3
  73. package/canon/tools/rtk.md +7 -7
  74. package/canon/tools/serena.md +6 -6
  75. package/dist/agents/contracts.js +1 -1
  76. package/dist/agents/host.js +4 -0
  77. package/dist/agents/process-incarnation.js +1 -1
  78. package/dist/agents/process.js +74 -6
  79. package/dist/agents/providers.js +13 -0
  80. package/dist/agents/supervision.js +153 -0
  81. package/dist/agents/telemetry.js +33 -0
  82. package/dist/agents/windows-launch.js +80 -0
  83. package/dist/canon/manifest.js +1 -1
  84. package/dist/change/inbox.js +21 -5
  85. package/dist/cli.js +19 -10
  86. package/dist/dashboard/discovery.js +73 -0
  87. package/dist/dashboard/page.js +122 -28
  88. package/dist/dashboard/panels.js +91 -15
  89. package/dist/goals/command.js +4 -2
  90. package/dist/loop/claims.js +1 -1
  91. package/dist/loop/decision.js +2 -2
  92. package/dist/loop/git.js +12 -4
  93. package/dist/loop/loop.js +8 -4
  94. package/dist/loop/parallel-adapters.js +2 -3
  95. package/dist/loop/parallel-command.js +5 -0
  96. package/dist/loop/prd.js +3 -1
  97. package/dist/loop/reporter.js +4 -1
  98. package/dist/loop/run-command.js +11 -2
  99. package/dist/loop/runner.js +22 -26
  100. package/dist/loop/watchdog.js +87 -11
  101. package/dist/loop/worker.js +5 -3
  102. package/dist/prd/assess.js +145 -0
  103. package/dist/prd/command.js +76 -38
  104. package/dist/quality/types.js +1 -1
  105. package/dist/retrofit/config.js +11 -0
  106. package/dist/retrofit/plan.js +2 -0
  107. package/dist/retrofit/planners/claude.js +14 -14
  108. package/dist/retrofit/planners/qwen.js +73 -0
  109. package/dist/retrofit/preserve.js +2 -2
  110. package/dist/retrofit/skill-actions.js +1 -0
  111. package/dist/review/command.js +1 -1
  112. package/dist/routing/assessment.js +1 -1
  113. package/dist/routing/capability.js +25 -13
  114. package/dist/routing/contracts.js +60 -0
  115. package/dist/routing/planning.js +12 -0
  116. package/dist/routing/router.js +51 -16
  117. package/dist/setup/command.js +11 -3
  118. package/docs/BATCH-PLANNING-VALIDATION.md +67 -0
  119. package/docs/CAPABILITY-ROUTING.md +78 -50
  120. package/docs/DASHBOARD-EVOLUTION.md +33 -0
  121. package/docs/MIGRATING-TO-1.0.md +33 -33
  122. package/docs/MIGRATING-TO-1.1.md +27 -27
  123. package/docs/MIGRATING-TO-1.4.md +70 -70
  124. package/docs/PRODUCT-DIRECTION-2026-09-05.md +218 -200
  125. package/docs/PUBLISHING.md +114 -114
  126. package/docs/VERIFIED-PROJECTS-VALIDATION.md +29 -29
  127. package/docs/VERIFIED-PROJECTS.md +167 -167
  128. package/docs/WINDOWS-RUNNER-VALIDATION.md +104 -0
  129. package/docs/assets/yoke-logo.png +0 -0
  130. package/docs/community-outreach-2026-08-20.md +85 -0
  131. package/docs/launch-copy-2026-08-21.md +193 -0
  132. package/docs/superpowers/plans/2026-06-28-baustein-e-context-layer.md +981 -981
  133. package/docs/superpowers/plans/2026-06-29-baustein-f-routing.md +258 -258
  134. package/docs/superpowers/plans/2026-06-29-baustein-g-loop-observability.md +1006 -1006
  135. package/docs/superpowers/plans/2026-06-29-baustein-h-loop-robustness.md +374 -374
  136. package/docs/superpowers/plans/2026-06-30-baustein-i-visual-design-verification.md +450 -450
  137. package/docs/superpowers/plans/2026-07-02-baustein-k-zero-to-100-bootstrap.md +1024 -1024
  138. package/docs/superpowers/plans/2026-07-02-baustein-m-flow-smoke-proofs.md +574 -574
  139. package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -537
  140. package/docs/superpowers/plans/2026-08-16-artifact-backed-output-compaction.md +329 -329
  141. package/docs/superpowers/plans/2026-09-05-verified-projects.md +83 -83
  142. package/docs/superpowers/specs/2026-06-28-baustein-e-context-layer-design.md +146 -146
  143. package/docs/superpowers/specs/2026-06-29-baustein-f-routing-design.md +106 -106
  144. package/docs/superpowers/specs/2026-06-29-baustein-g-loop-observability-design.md +186 -186
  145. package/docs/superpowers/specs/2026-06-29-baustein-h-loop-robustness-design.md +113 -113
  146. package/docs/superpowers/specs/2026-06-30-baustein-i-visual-design-verification-design.md +98 -98
  147. package/docs/superpowers/specs/2026-07-02-baustein-k-zero-to-100-bootstrap-design.md +200 -200
  148. package/docs/superpowers/specs/2026-07-02-baustein-m-flow-smoke-proofs-design.md +155 -155
  149. package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -422
  150. package/docs/superpowers/specs/2026-08-16-artifact-backed-output-compaction-design.md +166 -166
  151. package/gemini-extension.json +6 -6
  152. package/hooks/hooks.json +19 -19
  153. package/package.json +87 -87
@@ -0,0 +1,153 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdirSync, writeFileSync, renameSync, readFileSync, readdirSync, lstatSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { processIncarnation } from './process-incarnation.js';
5
+ import { loadConfig } from '../retrofit/config.js';
6
+ export function supervisionLimits(cwd) {
7
+ const loop = loadConfig(cwd)?.loop;
8
+ return { totalMs: (loop?.maxCallMinutes ?? 30) * 60_000, progressMs: (loop?.progressTimeoutMinutes ?? 20) * 60_000 };
9
+ }
10
+ export function assertPreviousProvidersStopped(cwd) {
11
+ if (readSupervision(cwd, '').some(record => record.state === 'termination-unconfirmed' && record.liveness !== 'exited')) {
12
+ throw Error('A previous provider termination is unconfirmed; inspect owned process records before starting another worker');
13
+ }
14
+ }
15
+ /** Only provider error/tool-result envelopes are failure evidence; stderr MCP chatter is not. */
16
+ export function inspectProviderEvent(line) {
17
+ let event;
18
+ try {
19
+ event = JSON.parse(line);
20
+ }
21
+ catch {
22
+ return {};
23
+ }
24
+ if (!event || typeof event !== 'object')
25
+ return {};
26
+ const item = event.item;
27
+ const failedCommand = event.type === 'item.completed' && item?.type === 'command_execution' && (item.status === 'failed' || (typeof item.exit_code === 'number' && item.exit_code !== 0));
28
+ const fatal = event.type === 'error' || event.type === 'turn.failed' || (event.type === 'result' && (event.is_error === true || event.status === 'error'));
29
+ if (failedCommand || fatal) {
30
+ const details = JSON.stringify(failedCommand ? item.aggregated_output : event.error ?? event.errors ?? event.result ?? event.message);
31
+ if (/CreateProcess(?:AsUserW|W)? failed|Failed to create unified exec process/iu.test(details ?? ''))
32
+ return { failure: 'worker-shell-spawn-failed' };
33
+ if (/AuthRequired|authentication failed|invalid api key|No access token|credentials (?:missing|not found)/iu.test(details ?? ''))
34
+ return { failure: 'provider-authentication-failed' };
35
+ if (fatal)
36
+ return { failure: 'provider-terminal-error' };
37
+ }
38
+ return { progress: (event.type === 'item.completed' && ((item?.type === 'command_execution' && item.exit_code === 0) || (item?.type === 'file_change' && item.status === 'completed')))
39
+ || (event.type === 'tool_result' && event.status === 'success')
40
+ || (event.type === 'user' && Array.isArray(event.message?.content) && event.message.content.some((part) => part.type === 'tool_result' && part.is_error === false)) };
41
+ }
42
+ export function inspectProviderDiagnostic(line) {
43
+ if (/\bERROR codex_core::tools::router: error=exec_command failed: CreateProcess\b/u.test(line) && /CreateProcess(?:AsUserW|W)? failed/u.test(line))
44
+ return 'worker-shell-spawn-failed';
45
+ // Unrelated rmcp/MCP authentication diagnostics are intentionally excluded.
46
+ if (/^(?:Error: |error: )(?:authentication failed|invalid api key|No access token)/iu.test(line))
47
+ return 'provider-authentication-failed';
48
+ return undefined;
49
+ }
50
+ export function createSupervision(cwd, onFailure, onProgress = () => { }, attempt = 1) {
51
+ const id = randomUUID(), dir = join(cwd, '.yoke', 'supervision'), path = join(dir, id + '.json');
52
+ const now = () => new Date().toISOString();
53
+ const state = { id, supervisorPid: process.pid, startedAt: now(), heartbeatAt: now(), state: 'preflight', retry: Math.max(0, attempt - 1) };
54
+ let pending = '', stderrPending = '', terminal = false, failure;
55
+ const save = () => {
56
+ state.heartbeatAt = now();
57
+ mkdirSync(dir, { recursive: true });
58
+ const temp = path + '.tmp';
59
+ writeFileSync(temp, JSON.stringify(state));
60
+ renameSync(temp, path);
61
+ };
62
+ save();
63
+ const timer = setInterval(() => { if (!terminal)
64
+ save(); }, 5000);
65
+ timer.unref();
66
+ return {
67
+ state,
68
+ flush() { if (pending)
69
+ this.output('stdout', '\n'); if (stderrPending)
70
+ this.output('stderr', '\n'); },
71
+ start(pid, shell, incarnation) { state.childPid = pid; state.childIncarnation = incarnation; state.shell = shell; state.state = 'running'; save(); },
72
+ output(stream, text) {
73
+ state.lastOutputAt = now();
74
+ const lines = ((stream === 'stdout' ? pending : stderrPending) + text).split(/\r?\n/u);
75
+ const tail = lines.pop() ?? '';
76
+ if (stream === 'stdout')
77
+ pending = tail.length > 262144 ? '' : tail;
78
+ else
79
+ stderrPending = tail.length > 262144 ? '' : tail;
80
+ for (const line of lines) {
81
+ if (line.length > 262144)
82
+ continue;
83
+ const result = stream === 'stdout' ? inspectProviderEvent(line) : { failure: inspectProviderDiagnostic(line) };
84
+ if (result.progress) {
85
+ state.lastProgressAt = now();
86
+ onProgress();
87
+ }
88
+ if (result.failure && !failure) {
89
+ failure = result.failure;
90
+ state.reason = failure;
91
+ state.state = 'stopping';
92
+ save();
93
+ onFailure(failure);
94
+ }
95
+ }
96
+ },
97
+ stop(reason, confirmed = true) { terminal = true; clearInterval(timer); state.reason = failure ?? reason; state.state = confirmed ? 'stopped' : 'termination-unconfirmed'; save(); },
98
+ get failure() { return failure; },
99
+ };
100
+ }
101
+ export function readSupervision(root, since) {
102
+ const roots = [root];
103
+ try {
104
+ for (const name of readdirSync(join(root, '.yoke', 'worktrees')).slice(0, 100)) {
105
+ const path = join(root, '.yoke', 'worktrees', name);
106
+ if (lstatSync(path).isDirectory() && !lstatSync(path).isSymbolicLink())
107
+ roots.push(path);
108
+ }
109
+ }
110
+ catch { /* no isolated workers */ }
111
+ const records = [];
112
+ for (const cwd of roots) {
113
+ const dir = join(cwd, '.yoke', 'supervision');
114
+ try {
115
+ if (lstatSync(dir).isSymbolicLink())
116
+ continue;
117
+ for (const name of readdirSync(dir).filter(n => n.endsWith('.json')).slice(-200)) {
118
+ const path = join(dir, name), stat = lstatSync(path);
119
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 8192)
120
+ continue;
121
+ try {
122
+ const record = JSON.parse(readFileSync(path, 'utf8'));
123
+ if (typeof record.startedAt === 'string' && record.startedAt >= since && typeof record.heartbeatAt === 'string')
124
+ records.push(record);
125
+ }
126
+ catch { /* incomplete/untrusted state */ }
127
+ }
128
+ }
129
+ catch { /* no process evidence */ }
130
+ }
131
+ return records.sort((a, b) => b.startedAt.localeCompare(a.startedAt)).slice(0, 20).map(record => ({ ...record, liveness: processLiveness(record) }));
132
+ }
133
+ const identities = new Map();
134
+ function processLiveness(record) {
135
+ if (!Number.isInteger(record.childPid) || record.childPid <= 0)
136
+ return 'identity-unverified';
137
+ try {
138
+ process.kill(record.childPid, 0);
139
+ }
140
+ catch (error) {
141
+ return error.code === 'ESRCH' ? 'exited' : 'identity-unverified';
142
+ }
143
+ if (!record.childIncarnation)
144
+ return 'identity-unverified';
145
+ let identity = identities.get(record.childPid);
146
+ if (!identity || Date.now() - identity.at > 5000) {
147
+ identity = { at: Date.now(), value: processIncarnation(record.childPid) };
148
+ identities.set(record.childPid, identity);
149
+ if (identities.size > 100)
150
+ identities.delete(identities.keys().next().value);
151
+ }
152
+ return identity.value === record.childIncarnation ? 'alive' : 'identity-unverified';
153
+ }
@@ -45,6 +45,10 @@ export function parseProviderResult(agent, output) {
45
45
  if (event.type === 'message' && event.role === 'assistant' && typeof event.content === 'string')
46
46
  fragments.push(event.content);
47
47
  break;
48
+ case 'qwen':
49
+ if (event.type === 'message' && event.role === 'assistant' && typeof event.content === 'string')
50
+ fragments.push(event.content);
51
+ break;
48
52
  }
49
53
  }
50
54
  const joined = parseJson(fragments.join(''));
@@ -130,6 +134,35 @@ export function parseProviderTelemetry(agent, lines) {
130
134
  if (aggregateCached !== undefined)
131
135
  source.cached_input_tokens = aggregateCached;
132
136
  }
137
+ if (agent === 'qwen' && modelEntries.length > 0) {
138
+ reportedModels = modelEntries.map(([name]) => name);
139
+ model = firstModel?.[0];
140
+ const fields = {
141
+ input_tokens: ['input_tokens', 'inputTokens', 'promptTokenCount', 'input'],
142
+ output_tokens: ['output_tokens', 'outputTokens', 'candidatesTokenCount', 'output'],
143
+ cached_input_tokens: ['cached_input_tokens', 'cachedInputTokens', 'cachedContentTokenCount', 'cached'],
144
+ reasoning_output_tokens: ['reasoning_output_tokens', 'reasoningOutputTokens', 'thoughtsTokenCount', 'thoughts'],
145
+ };
146
+ const totals = {};
147
+ for (const [field, aliases] of Object.entries(fields)) {
148
+ const aggregate = aliases.map(key => finite(usage?.[key])).find(value => value !== undefined);
149
+ if (aggregate !== undefined) {
150
+ totals[field] = aggregate;
151
+ continue;
152
+ }
153
+ const values = modelEntries.map(([, value]) => {
154
+ const entry = isRecord(value) ? value : {};
155
+ const tokens = isRecord(entry.tokens) ? entry.tokens : entry;
156
+ return aliases.map(key => finite(tokens[key])).find(value => value !== undefined);
157
+ });
158
+ if (values.every(value => value !== undefined))
159
+ totals[field] = values.reduce((sum, value) => sum + value, 0);
160
+ }
161
+ source = { ...totals, ...usage };
162
+ const aggregateCached = finite(usage?.cached_input_tokens ?? usage?.cached);
163
+ if (aggregateCached !== undefined)
164
+ source.cached_input_tokens = aggregateCached;
165
+ }
133
166
  const inValue = finite(source?.input_tokens ?? source?.inputTokens ?? source?.prompt_tokens ?? source?.promptTokenCount ?? source?.input);
134
167
  const cachedValue = finite(source?.cached_input_tokens ?? source?.cache_read_input_tokens ?? source?.cachedInputTokens ?? source?.cachedContentTokenCount ?? source?.cached);
135
168
  const cacheWriteValue = finite(source?.cache_write_input_tokens ?? source?.cache_creation_input_tokens ?? source?.cacheWriteInputTokens ?? source?.cacheWrite);
@@ -0,0 +1,80 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import { delimiter, dirname, extname, isAbsolute, join, resolve } from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { fileURLToPath, pathToFileURL } from 'node:url';
5
+ import { createRequire } from 'node:module';
6
+ const file = (path) => { try {
7
+ return statSync(path).isFile();
8
+ }
9
+ catch {
10
+ return false;
11
+ } };
12
+ const pathValue = (env) => env[Object.keys(env).find(k => k.toLowerCase() === 'path') ?? 'PATH'] ?? '';
13
+ /** Resolve npm launchers without cmd.exe argument concatenation. */
14
+ export function resolveWindowsCommand(command, args, env = process.env) {
15
+ if (command === 'node' || command === process.execPath)
16
+ return { command: process.execPath, args: [...args] };
17
+ if (isAbsolute(command) && /\.(exe|com)$/iu.test(command))
18
+ return { command, args: [...args] };
19
+ const candidates = isAbsolute(command) || /[\\/]/u.test(command) ? [command]
20
+ : pathValue(env).split(';').filter(Boolean).flatMap(dir => extname(command) ? [join(dir, command)] : ['.exe', '.com', '.cmd', '.bat'].map(ext => join(dir, command + ext)));
21
+ const found = candidates.find(file);
22
+ if (!found)
23
+ throw Error(`Provider executable unavailable: ${command}`);
24
+ if (/\.(exe|com)$/iu.test(found))
25
+ return { command: found, args: [...args] };
26
+ const script = readFileSync(found, 'utf8');
27
+ const entry = script.match(/"%dp0%[\\/]([^"\r\n]+\.(?:m?js|cjs))"/iu)?.[1];
28
+ if (!entry)
29
+ throw Error('Unsupported Windows batch launcher; use a native executable or an npm-generated CLI launcher');
30
+ const target = resolve(dirname(found), ...entry.split(/[\\/]/u));
31
+ if (!file(target))
32
+ throw Error('Provider npm entry point is missing');
33
+ const localNode = join(dirname(found), 'node.exe');
34
+ return { command: file(localNode) ? localNode : process.execPath, args: [target, ...args] };
35
+ }
36
+ export function inaccessibleStoreShell(path) {
37
+ const bad = (p) => /[\\/]WindowsApps[\\/](?:Microsoft\.PowerShell[^\\/]*[\\/]|(?:pwsh|powershell)\.exe$)/iu.test(p);
38
+ if (bad(path))
39
+ return true;
40
+ try {
41
+ return bad(realpathSync(path));
42
+ }
43
+ catch {
44
+ return false;
45
+ }
46
+ }
47
+ export function windowsShellEnvironment(env = process.env) {
48
+ // Change only this provider's PATH. Never change machine/user configuration or permissions.
49
+ const paths = pathValue(env).split(';').filter(Boolean).filter(dir => !['pwsh.exe', 'powershell.exe'].some(name => inaccessibleStoreShell(join(dir, name))));
50
+ const nativePwsh = join(env.ProgramFiles ?? 'C:\\Program Files', 'PowerShell', '7', 'pwsh.exe');
51
+ const systemShell = join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
52
+ const shell = [...paths.map(p => join(p, 'pwsh.exe')), nativePwsh, ...paths.map(p => join(p, 'powershell.exe')), systemShell].find(p => file(p) && !inaccessibleStoreShell(p));
53
+ if (!shell)
54
+ throw Error('No native PowerShell available for sandbox preflight');
55
+ const next = { ...env };
56
+ for (const key of Object.keys(next))
57
+ if (key.toLowerCase() === 'path')
58
+ delete next[key];
59
+ next.PATH = [dirname(shell), ...paths].join(delimiter);
60
+ return { env: next, shell };
61
+ }
62
+ /** Model-free probe under the same Codex permission profile and isolated cwd. */
63
+ export function prepareWindowsInvocation(inv, env = process.env) {
64
+ if (inv.command !== 'codex' || !inv.args.includes('exec') || inv.args.includes('--dangerously-bypass-approvals-and-sandbox'))
65
+ return { ...resolveWindowsCommand(inv.command, inv.args, env), env };
66
+ const prepared = windowsShellEnvironment(env);
67
+ const readOnly = inv.args.includes('read-only');
68
+ const config = ['--config', `shell_environment_policy.set.PATH=${JSON.stringify(prepared.env.PATH)}`];
69
+ const probe = resolveWindowsCommand('codex', ['sandbox', '-P', readOnly ? ':read-only' : ':workspace', '-C', inv.cwd, ...config, '--', prepared.shell, '-NoProfile', '-NonInteractive', '-Command', 'Write-Output YOKE_SHELL_OK'], prepared.env);
70
+ const compiled = fileURLToPath(new URL('../loop/watchdog.js', import.meta.url));
71
+ const watchdog = existsSync(compiled) ? [compiled] : ['--import', pathToFileURL(createRequire(import.meta.url).resolve('tsx')).href, fileURLToPath(new URL('../loop/watchdog.ts', import.meta.url))];
72
+ // The inner watchdog owns the native process tree. Killing only an npm wrapper
73
+ // on spawnSync timeout could otherwise leave the sandbox helper behind.
74
+ const result = spawnSync(process.execPath, [...watchdog, '--idle-ms=30000', '--total-ms=30000', '--', probe.command, ...probe.args], { cwd: inv.cwd, env: prepared.env, shell: false, windowsHide: true, encoding: 'utf8', timeout: 90_000, maxBuffer: 64 * 1024 });
75
+ if (result.status !== 0 || !result.stdout?.includes('YOKE_SHELL_OK')) {
76
+ const signature = /CreateProcessAsUserW failed:\s*-?\d+/u.exec(result.stderr ?? '')?.[0];
77
+ throw Error(`Codex sandbox preflight failed (${signature ?? result.error?.code ?? result.status ?? 'no completion'}); no model started. Check native PowerShell and Codex sandbox setup.`);
78
+ }
79
+ return { ...resolveWindowsCommand(inv.command, [...inv.args, ...config], prepared.env), ...prepared };
80
+ }
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { parse } from 'yaml';
3
3
  import { readFileSync } from 'node:fs';
4
- export const AgentSchema = z.enum(['claude', 'codex', 'gemini']);
4
+ export const AgentSchema = z.enum(['claude', 'codex', 'gemini', 'qwen']);
5
5
  export const InvocationSchema = z.enum(['auto', 'manual']);
6
6
  export const SkillEntrySchema = z.object({
7
7
  id: z.string().min(1),
@@ -1,4 +1,8 @@
1
1
  import { assessmentInstructions } from "../routing/assessment.js";
2
+ import { bindAssessments, preparedProblems } from '../prd/assess.js';
3
+ import { loadConfig } from '../retrofit/config.js';
4
+ import { resolvePlanner } from '../routing/planning.js';
5
+ import { readPlanningFile } from '../routing/contracts.js';
2
6
  import { randomUUID } from 'node:crypto';
3
7
  import { execFileSync } from 'node:child_process';
4
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
@@ -88,13 +92,14 @@ export function pendingChanges(targetDir) {
88
92
  .map(name => ChangeRequestSchema.parse(JSON.parse(readFileSync(join(dir, name), 'utf8'))))
89
93
  .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
90
94
  }
91
- export function buildChangePrompt(request, proposalPath, stories) {
95
+ export function buildChangePrompt(request, proposalPath, stories, brief = '') {
92
96
  return [
93
97
  'You are planning one newly requested product change for the Yoke autonomous loop.',
94
98
  `Change request ${request.id}: ${request.request}`,
95
99
  '',
96
100
  'Create an append-only proposal: add small new stories; never rewrite or delete existing stories.',
97
101
  assessmentInstructions,
102
+ ...(brief ? ['Approved planning brief:', brief] : []),
98
103
  `Existing story IDs: ${stories.map(story => story.id).join(', ') || '(none)'}`,
99
104
  'Every proposed story must have passes: false and 2-5 structured acceptance criteria.',
100
105
  'Every criterion must have a stable id, behavioral text, and one or more executable verify commands.',
@@ -128,6 +133,9 @@ function archiveRequest(targetDir, request) {
128
133
  rmSync(reviewFile(targetDir, request.id), { force: true });
129
134
  }
130
135
  export function runChangeApply(targetDir, opts) {
136
+ const config = loadConfig(targetDir);
137
+ const planner = resolvePlanner(config, opts.runner, opts.selection);
138
+ opts = { ...opts, runner: planner.agent, selection: planner.selection };
131
139
  let requests;
132
140
  try {
133
141
  requests = pendingChanges(targetDir);
@@ -170,15 +178,16 @@ export function runChangeApply(targetDir, opts) {
170
178
  if (!available(reviewer))
171
179
  return { ok: false, added: 0, summary: `reviewer CLI "${reviewer}" is unavailable`, changeId: request.id };
172
180
  const proposal = proposalFile(targetDir, request.id);
181
+ const brief = readPlanningFile(targetDir, '.yoke/plan.md', 80_000) ?? '';
173
182
  const plannerDir = changesDir(targetDir);
174
183
  mkdirSync(plannerDir, { recursive: true });
175
184
  rmSync(proposal, { force: true });
176
- const base = agentInvocation(opts.runner, buildChangePrompt(request, proposal, existing), plannerDir, opts.permissions ?? 'safe', opts.selection);
185
+ const base = agentInvocation(opts.runner, buildChangePrompt(request, proposal, existing, brief), plannerDir, opts.permissions ?? 'safe', opts.selection);
177
186
  const invocation = buildWatchdogInvocation(base, opts.timeoutMs ?? 0);
178
187
  const result = (opts.run ?? runAgent)(invocation);
179
188
  if (!result.success)
180
189
  return { ok: false, added: 0, summary: `planner failed: ${result.summary}`, changeId: request.id };
181
- if (readFileSync(prdPath, 'utf8') !== existingText) {
190
+ if (readFileSync(prdPath, 'utf8') !== existingText || (readPlanningFile(targetDir, '.yoke/plan.md', 80_000) ?? '') !== brief) {
182
191
  return {
183
192
  ok: false,
184
193
  added: 0,
@@ -221,12 +230,19 @@ export function runChangeApply(targetDir, opts) {
221
230
  }
222
231
  proposedIds.add(story.id);
223
232
  }
224
- const appended = proposed.map(story => ({ ...story, sourceChange: request.id }));
233
+ let appended = proposed.map(story => ({ ...story, sourceChange: request.id }));
225
234
  const dependencyIssues = validateDependencies([...existing, ...appended]);
226
235
  if (dependencyIssues.length > 0) {
227
236
  return { ok: false, added: 0, summary: `invalid combined dependency graph: ${dependencyIssues.join('; ')}`, changeId: request.id };
228
237
  }
229
238
  const reviewPath = reviewFile(targetDir, request.id);
239
+ const bound = bindAssessments([...existing, ...appended], brief);
240
+ appended = bound.slice(existing.length).map(s => ({ ...s, sourceChange: request.id }));
241
+ if (config?.routing?.assessmentPolicy === 'prepared') {
242
+ const issues = preparedProblems(bound, brief).filter(issue => appended.some(s => issue.startsWith(s.id + ':')));
243
+ if (issues.length)
244
+ return { ok: false, added: 0, summary: issues.join('; '), changeId: request.id };
245
+ }
230
246
  rmSync(reviewPath, { force: true });
231
247
  const reviewBase = agentInvocation(reviewer, buildChangeReviewPrompt(request, reviewPath, appended), plannerDir, opts.permissions ?? 'safe', reviewer === opts.runner ? opts.selection : undefined);
232
248
  const reviewInvocation = buildWatchdogInvocation(reviewBase, opts.timeoutMs ?? 0);
@@ -234,7 +250,7 @@ export function runChangeApply(targetDir, opts) {
234
250
  if (!reviewResult.success) {
235
251
  return { ok: false, added: 0, summary: `coverage review failed: ${reviewResult.summary}`, changeId: request.id };
236
252
  }
237
- if (readFileSync(prdPath, 'utf8') !== existingText) {
253
+ if (readFileSync(prdPath, 'utf8') !== existingText || (readPlanningFile(targetDir, '.yoke/plan.md', 80_000) ?? '') !== brief) {
238
254
  return {
239
255
  ok: false, added: 0,
240
256
  summary: 'PRD changed while the coverage reviewer was running; refusing to overwrite concurrent or out-of-contract edits',
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import { runReview } from './review/command.js';
13
13
  import { scanDir } from './scan/design.js';
14
14
  import { runNew } from './new/command.js';
15
15
  import { runPrdDraft, runPrdCheck } from './prd/command.js';
16
+ import { runPrdAssess } from './prd/assess.js';
16
17
  import { runLoopCleanup } from './loop/cleanup.js';
17
18
  import { runFlowSmoke } from './smoke/command.js';
18
19
  import { maybeNotifyUpdate, currentYokeVersion } from './update/check.js';
@@ -103,7 +104,7 @@ export function main(argv) {
103
104
  switch (cmd) {
104
105
  case 'setup': {
105
106
  const targetDir = rest.find(a => !a.startsWith('-')) ?? '.';
106
- const valid = ['claude', 'codex', 'gemini'];
107
+ const valid = ['claude', 'codex', 'gemini', 'qwen'];
107
108
  const hostArg = rest.find(a => a.startsWith('--host='))?.slice('--host='.length);
108
109
  if (hostArg && !valid.includes(hostArg)) {
109
110
  console.error(`Invalid --host value: ${hostArg}`);
@@ -225,7 +226,7 @@ export function main(argv) {
225
226
  }
226
227
  if (sub === 'run' || sub === 'resume') {
227
228
  const provider = value('runner') ?? 'codex';
228
- if (!['codex', 'claude', 'gemini'].includes(provider))
229
+ if (!['codex', 'claude', 'gemini', 'qwen'].includes(provider))
229
230
  throw new Error('Unknown runner');
230
231
  return runProjectGoal(targetDir, { provider: provider, selection: { model: value('model') } }).then(goal => {
231
232
  console.log(JSON.stringify(goal));
@@ -268,7 +269,7 @@ export function main(argv) {
268
269
  const targetDir = rest.find(a => !a.startsWith('-')) ?? '.';
269
270
  const loop = rest.includes('--loop');
270
271
  const agentArg = rest.find(a => a.startsWith('--agent='))?.slice('--agent='.length);
271
- const all = ['claude', 'codex', 'gemini'];
272
+ const all = ['claude', 'codex', 'gemini', 'qwen'];
272
273
  const agents = !agentArg || agentArg === 'all'
273
274
  ? (agentArg === 'all' ? all : undefined)
274
275
  : agentArg.split(',').filter((a) => all.includes(a));
@@ -467,10 +468,10 @@ export function main(argv) {
467
468
  return 1;
468
469
  }
469
470
  const runnerArg = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
470
- const valid = ['claude', 'codex', 'gemini'];
471
+ const valid = ['claude', 'codex', 'gemini', 'qwen'];
471
472
  const agent = runnerArg && valid.includes(runnerArg) ? runnerArg : undefined;
472
473
  if (runnerArg && !agent) {
473
- console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini)`);
474
+ console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini|qwen)`);
474
475
  return 1;
475
476
  }
476
477
  const isolate = rest.includes('--isolate') ? true : rest.includes('--no-isolate') ? false : undefined;
@@ -533,7 +534,7 @@ export function main(argv) {
533
534
  const idea = rest.find(a => a.startsWith('--idea='))?.slice('--idea='.length);
534
535
  const loop = rest.includes('--loop');
535
536
  const agentArg = rest.find(a => a.startsWith('--agent='))?.slice('--agent='.length);
536
- const all = ['claude', 'codex', 'gemini'];
537
+ const all = ['claude', 'codex', 'gemini', 'qwen'];
537
538
  const agents = !agentArg || agentArg === 'all'
538
539
  ? (agentArg === 'all' ? all : undefined)
539
540
  : agentArg.split(',').filter((a) => all.includes(a));
@@ -550,16 +551,24 @@ export function main(argv) {
550
551
  case 'prd': {
551
552
  const sub = rest[0];
552
553
  const targetDir = rest.slice(1).find(a => !a.startsWith('-')) ?? '.';
554
+ if (sub === 'assess') {
555
+ const runner = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
556
+ if (runner && !['codex', 'claude', 'gemini', 'qwen'].includes(runner)) {
557
+ console.error('Invalid planning runner');
558
+ return 1;
559
+ }
560
+ return runPrdAssess(targetDir, { runner: runner, story: rest.find(a => a.startsWith('--story='))?.slice('--story='.length), reassess: rest.includes('--reassess') });
561
+ }
553
562
  if (sub === 'draft') {
554
563
  const idea = rest.find(a => a.startsWith('--idea='))?.slice('--idea='.length);
555
564
  if (!idea) {
556
565
  console.error('usage: yoke prd draft [dir] --idea="..." [--runner=<claude|codex|gemini>] [--force] [--timeout=<minutes>]');
557
566
  return 1;
558
567
  }
559
- const valid = ['claude', 'codex', 'gemini'];
568
+ const valid = ['claude', 'codex', 'gemini', 'qwen'];
560
569
  const runnerArg = rest.find(a => a.startsWith('--runner='))?.slice('--runner='.length);
561
570
  if (runnerArg && !valid.includes(runnerArg)) {
562
- console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini)`);
571
+ console.error(`Invalid --runner value: ${runnerArg} (expected claude|codex|gemini|qwen)`);
563
572
  return 1;
564
573
  }
565
574
  const force = rest.includes('--force');
@@ -577,7 +586,7 @@ export function main(argv) {
577
586
  }
578
587
  if (sub === 'check')
579
588
  return runPrdCheck(targetDir);
580
- console.log('usage: yoke prd <draft|check> [dir] [--idea="..."] [--runner=<claude|codex|gemini>] [--force] [--timeout=<minutes>]');
589
+ console.log('usage: yoke prd <draft|check|assess> [dir] [--idea="..."] [--runner=<claude|codex|gemini>] [--story=<id>] [--reassess] [--force] [--timeout=<minutes>]');
581
590
  return 1;
582
591
  }
583
592
  case 'context': {
@@ -642,7 +651,7 @@ export function main(argv) {
642
651
  return runUpgrade();
643
652
  default:
644
653
  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]');
645
- 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> [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>');
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>');
646
655
  return cmd ? 1 : 0;
647
656
  }
648
657
  }
@@ -0,0 +1,73 @@
1
+ import { opendir, lstat, realpath } from 'node:fs/promises';
2
+ import { delimiter, dirname, join, parse, resolve } from 'node:path';
3
+ import { autoRegisterProject, listProjects } from './registry.js';
4
+ const skip = new Set(['node_modules', 'vendor', 'dist', 'build', 'coverage', 'bench', 'fixtures']);
5
+ export function discoveryRoots() {
6
+ const configured = process.env.YOKE_PROJECT_ROOTS?.split(delimiter).filter(Boolean);
7
+ const candidates = configured ?? [dirname(process.cwd()), ...listProjects().filter(p => !p.error).map(p => dirname(p.root))];
8
+ const roots = [...new Set(candidates.map(p => resolve(p)))].filter(p => p !== parse(p).root);
9
+ return roots.filter(p => !roots.some(other => other !== p && p.startsWith(other + (process.platform === 'win32' ? '\\' : '/'))));
10
+ }
11
+ /** Incremental traversal keeps directory IO out of the HTTP request path. */
12
+ export function startProjectDiscovery(roots = discoveryRoots) {
13
+ let stopped = false;
14
+ let active;
15
+ let queue = [];
16
+ let visited = new Set();
17
+ let current;
18
+ const scan = async () => {
19
+ if (!queue.length && !current) {
20
+ queue = roots();
21
+ visited = new Set();
22
+ }
23
+ let budget = 500;
24
+ while (!stopped && budget-- > 0 && (current || queue.length)) {
25
+ if (!current) {
26
+ const path = queue.shift();
27
+ try {
28
+ if ((await lstat(path)).isSymbolicLink())
29
+ continue;
30
+ const canonical = await realpath(path);
31
+ const key = process.platform === 'win32' ? canonical.toLowerCase() : canonical;
32
+ if (visited.has(key))
33
+ continue;
34
+ visited.add(key);
35
+ autoRegisterProject(canonical);
36
+ current = await opendir(canonical);
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ }
42
+ try {
43
+ const entry = await current.read();
44
+ if (!entry) {
45
+ await current.close();
46
+ current = undefined;
47
+ continue;
48
+ }
49
+ if (entry.isDirectory() && !entry.isSymbolicLink() && !entry.name.startsWith('.') && !skip.has(entry.name.toLowerCase()))
50
+ queue.push(join(current.path, entry.name));
51
+ }
52
+ catch {
53
+ try {
54
+ await current?.close();
55
+ }
56
+ catch { /* Already closed. */ }
57
+ current = undefined;
58
+ }
59
+ }
60
+ };
61
+ const tick = () => {
62
+ if (!active && !stopped)
63
+ active = scan().catch(() => { }).finally(() => { active = undefined; });
64
+ return active ?? Promise.resolve();
65
+ };
66
+ const timer = setInterval(() => { void tick(); }, 5000);
67
+ timer.unref();
68
+ void tick();
69
+ return { tick, get pending() { return queue.length + (current ? 1 : 0); }, async close() { stopped = true; clearInterval(timer); await active; try {
70
+ await current?.close();
71
+ }
72
+ catch { /* Already closed. */ } } };
73
+ }