@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.
- package/.claude-plugin/plugin.json +13 -13
- package/.codex-plugin/plugin.json +7 -7
- package/CHANGELOG.md +398 -358
- package/README.md +915 -913
- package/TODOS.md +5 -5
- package/agents/docs.toml +6 -6
- package/agents/implementer.toml +6 -6
- package/agents/reviewer.toml +6 -6
- package/agents/security.toml +6 -6
- package/bench/README.md +86 -86
- package/bench/RESULTS.md +35 -35
- package/bench/output-compaction.mjs +65 -65
- package/bench/result-schema.mjs +12 -12
- package/bench/results/claude-2026-07-27T18-03-26.json +50 -50
- package/bench/results/codex-unavailable-1785175418318.json +15 -15
- package/bench/results/gemini-2026-07-27T18-03-44.json +46 -46
- package/bench/run-matrix.mjs +26 -26
- package/bench/run.mjs +106 -106
- package/canon/AGENTS.md +30 -30
- package/canon/context/DECISIONS.md +4 -4
- package/canon/context/GLOSSARY.md +11 -11
- package/canon/context/KNOWLEDGE.md +4 -4
- package/canon/context/PROJECT.md +15 -15
- package/canon/loop/loop-spec.md +65 -65
- package/canon/loop/prd.schema.md +46 -40
- package/canon/manifest.yaml +59 -59
- package/canon/policy/gates.md +7 -7
- package/canon/policy/roles.md +9 -9
- package/canon/skills/ATTRIBUTION.md +99 -99
- package/canon/skills/authoring-prd/SKILL.md +56 -56
- package/canon/skills/brainstorming/SKILL.md +164 -164
- package/canon/skills/codebase-design/DEEPENING.md +15 -15
- package/canon/skills/codebase-design/DESIGN-IT-TWICE.md +12 -12
- package/canon/skills/codebase-design/SKILL.md +39 -39
- package/canon/skills/dispatching-parallel-agents/SKILL.md +182 -182
- package/canon/skills/document-release/SKILL.md +302 -302
- package/canon/skills/domain-modeling/ADR-FORMAT.md +19 -19
- package/canon/skills/domain-modeling/CONTEXT-FORMAT.md +39 -39
- package/canon/skills/domain-modeling/SKILL.md +35 -35
- package/canon/skills/executing-plans/SKILL.md +70 -70
- package/canon/skills/finishing-a-development-branch/SKILL.md +200 -200
- package/canon/skills/health/SKILL.md +177 -177
- package/canon/skills/maintaining-context/SKILL.md +34 -34
- package/canon/skills/minimal-code/SKILL.md +21 -21
- package/canon/skills/no-ai-slop/SKILL.md +103 -103
- package/canon/skills/no-ai-slop/eval.md +43 -43
- package/canon/skills/plan-ceo-review/SKILL.md +541 -541
- package/canon/skills/plan-eng-review/SKILL.md +362 -362
- package/canon/skills/receiving-code-review/SKILL.md +213 -213
- package/canon/skills/requesting-code-review/SKILL.md +105 -105
- package/canon/skills/resolving-merge-conflicts/SKILL.md +18 -18
- package/canon/skills/retro/SKILL.md +397 -397
- package/canon/skills/review/SKILL.md +246 -246
- package/canon/skills/ship/SKILL.md +691 -691
- package/canon/skills/subagent-driven-development/SKILL.md +277 -277
- package/canon/skills/systematic-debugging/SKILL.md +296 -296
- package/canon/skills/tdd/SKILL.md +371 -371
- package/canon/skills/unslop-ui/SKILL.md +34 -34
- package/canon/skills/using-git-worktrees/SKILL.md +218 -218
- package/canon/skills/verification-before-completion/SKILL.md +139 -139
- package/canon/skills/visual-verification/SKILL.md +54 -54
- package/canon/skills/workflow/SKILL.md +22 -22
- package/canon/skills/writing-for-agents/SKILL-MECHANICS.md +27 -27
- package/canon/skills/writing-for-agents/SKILL.md +42 -42
- package/canon/skills/writing-plans/SKILL.md +152 -152
- package/canon/skills/writing-skills/SKILL.md +655 -655
- package/canon/skills/yoke-retrofit/SKILL.md +26 -26
- package/canon/skills/yoke-workflow/SKILL.md +20 -20
- package/canon/tools/codex-rtk-hook.mjs +35 -35
- package/canon/tools/gemini-rtk-hook.mjs +25 -25
- package/canon/tools/graphify.md +3 -3
- package/canon/tools/playwright-mcp.md +3 -3
- package/canon/tools/rtk.md +7 -7
- package/canon/tools/serena.md +6 -6
- package/dist/agents/contracts.js +1 -1
- package/dist/agents/host.js +4 -0
- package/dist/agents/process-incarnation.js +1 -1
- package/dist/agents/process.js +74 -6
- package/dist/agents/providers.js +13 -0
- package/dist/agents/supervision.js +153 -0
- package/dist/agents/telemetry.js +33 -0
- package/dist/agents/windows-launch.js +80 -0
- package/dist/canon/manifest.js +1 -1
- package/dist/change/inbox.js +21 -5
- package/dist/cli.js +19 -10
- package/dist/dashboard/discovery.js +73 -0
- package/dist/dashboard/page.js +122 -28
- package/dist/dashboard/panels.js +91 -15
- package/dist/goals/command.js +4 -2
- package/dist/loop/claims.js +1 -1
- package/dist/loop/decision.js +2 -2
- package/dist/loop/git.js +12 -4
- package/dist/loop/loop.js +8 -4
- package/dist/loop/parallel-adapters.js +2 -3
- package/dist/loop/parallel-command.js +5 -0
- package/dist/loop/prd.js +3 -1
- package/dist/loop/reporter.js +4 -1
- package/dist/loop/run-command.js +11 -2
- package/dist/loop/runner.js +22 -26
- package/dist/loop/watchdog.js +87 -11
- package/dist/loop/worker.js +5 -3
- package/dist/prd/assess.js +145 -0
- package/dist/prd/command.js +76 -38
- package/dist/quality/types.js +1 -1
- package/dist/retrofit/config.js +11 -0
- package/dist/retrofit/plan.js +2 -0
- package/dist/retrofit/planners/claude.js +14 -14
- package/dist/retrofit/planners/qwen.js +73 -0
- package/dist/retrofit/preserve.js +2 -2
- package/dist/retrofit/skill-actions.js +1 -0
- package/dist/review/command.js +1 -1
- package/dist/routing/assessment.js +1 -1
- package/dist/routing/capability.js +25 -13
- package/dist/routing/contracts.js +60 -0
- package/dist/routing/planning.js +12 -0
- package/dist/routing/router.js +51 -16
- package/dist/setup/command.js +11 -3
- package/docs/BATCH-PLANNING-VALIDATION.md +67 -0
- package/docs/CAPABILITY-ROUTING.md +78 -50
- package/docs/DASHBOARD-EVOLUTION.md +33 -0
- package/docs/MIGRATING-TO-1.0.md +33 -33
- package/docs/MIGRATING-TO-1.1.md +27 -27
- package/docs/MIGRATING-TO-1.4.md +70 -70
- package/docs/PRODUCT-DIRECTION-2026-09-05.md +218 -200
- package/docs/PUBLISHING.md +114 -114
- package/docs/VERIFIED-PROJECTS-VALIDATION.md +29 -29
- package/docs/VERIFIED-PROJECTS.md +167 -167
- package/docs/WINDOWS-RUNNER-VALIDATION.md +104 -0
- package/docs/assets/yoke-logo.png +0 -0
- package/docs/community-outreach-2026-08-20.md +85 -0
- package/docs/launch-copy-2026-08-21.md +193 -0
- package/docs/superpowers/plans/2026-06-28-baustein-e-context-layer.md +981 -981
- package/docs/superpowers/plans/2026-06-29-baustein-f-routing.md +258 -258
- package/docs/superpowers/plans/2026-06-29-baustein-g-loop-observability.md +1006 -1006
- package/docs/superpowers/plans/2026-06-29-baustein-h-loop-robustness.md +374 -374
- package/docs/superpowers/plans/2026-06-30-baustein-i-visual-design-verification.md +450 -450
- package/docs/superpowers/plans/2026-07-02-baustein-k-zero-to-100-bootstrap.md +1024 -1024
- package/docs/superpowers/plans/2026-07-02-baustein-m-flow-smoke-proofs.md +574 -574
- package/docs/superpowers/plans/2026-08-13-gauntlet-quality-loop.md +537 -537
- package/docs/superpowers/plans/2026-08-16-artifact-backed-output-compaction.md +329 -329
- package/docs/superpowers/plans/2026-09-05-verified-projects.md +83 -83
- package/docs/superpowers/specs/2026-06-28-baustein-e-context-layer-design.md +146 -146
- package/docs/superpowers/specs/2026-06-29-baustein-f-routing-design.md +106 -106
- package/docs/superpowers/specs/2026-06-29-baustein-g-loop-observability-design.md +186 -186
- package/docs/superpowers/specs/2026-06-29-baustein-h-loop-robustness-design.md +113 -113
- package/docs/superpowers/specs/2026-06-30-baustein-i-visual-design-verification-design.md +98 -98
- package/docs/superpowers/specs/2026-07-02-baustein-k-zero-to-100-bootstrap-design.md +200 -200
- package/docs/superpowers/specs/2026-07-02-baustein-m-flow-smoke-proofs-design.md +155 -155
- package/docs/superpowers/specs/2026-08-13-gauntlet-quality-loop-design.md +422 -422
- package/docs/superpowers/specs/2026-08-16-artifact-backed-output-compaction-design.md +166 -166
- package/gemini-extension.json +6 -6
- package/hooks/hooks.json +19 -19
- 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
|
+
}
|
package/dist/agents/telemetry.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/canon/manifest.js
CHANGED
|
@@ -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),
|
package/dist/change/inbox.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|