openclaw-node-harness 2.0.3 → 2.1.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/README.md +646 -3
- package/bin/hyperagent.mjs +419 -0
- package/bin/mesh-agent.js +603 -81
- package/bin/mesh-bridge.js +340 -11
- package/bin/mesh-deploy-listener.js +119 -97
- package/bin/mesh-deploy.js +8 -0
- package/bin/mesh-task-daemon.js +1005 -40
- package/bin/mesh.js +423 -6
- package/config/claude-settings.json +95 -0
- package/config/daemon.json.template +2 -1
- package/config/git-hooks/pre-commit +13 -0
- package/config/git-hooks/pre-push +12 -0
- package/config/harness-rules.json +174 -0
- package/config/plan-templates/team-bugfix.yaml +52 -0
- package/config/plan-templates/team-deploy.yaml +50 -0
- package/config/plan-templates/team-feature.yaml +71 -0
- package/config/roles/qa-engineer.yaml +36 -0
- package/config/roles/solidity-dev.yaml +51 -0
- package/config/roles/tech-architect.yaml +36 -0
- package/config/rules/framework/solidity.md +22 -0
- package/config/rules/framework/typescript.md +21 -0
- package/config/rules/framework/unity.md +21 -0
- package/config/rules/universal/design-docs.md +18 -0
- package/config/rules/universal/git-hygiene.md +18 -0
- package/config/rules/universal/security.md +19 -0
- package/config/rules/universal/test-standards.md +19 -0
- package/identity/DELEGATION.md +6 -6
- package/install.sh +300 -8
- package/lib/circling-parser.js +119 -0
- package/lib/hyperagent-store.mjs +652 -0
- package/lib/kanban-io.js +59 -10
- package/lib/mcp-knowledge/bench.mjs +118 -0
- package/lib/mcp-knowledge/core.mjs +528 -0
- package/lib/mcp-knowledge/package.json +25 -0
- package/lib/mcp-knowledge/server.mjs +245 -0
- package/lib/mcp-knowledge/test.mjs +802 -0
- package/lib/memory-budget.mjs +261 -0
- package/lib/mesh-collab.js +354 -4
- package/lib/mesh-harness.js +427 -0
- package/lib/mesh-plans.js +13 -5
- package/lib/mesh-registry.js +11 -2
- package/lib/mesh-tasks.js +67 -0
- package/lib/plan-templates.js +226 -0
- package/lib/pre-compression-flush.mjs +320 -0
- package/lib/role-loader.js +292 -0
- package/lib/rule-loader.js +358 -0
- package/lib/session-store.mjs +458 -0
- package/lib/transcript-parser.mjs +292 -0
- package/mission-control/drizzle/soul_schema_update.sql +29 -0
- package/mission-control/drizzle.config.ts +1 -4
- package/mission-control/package-lock.json +1571 -83
- package/mission-control/package.json +6 -2
- package/mission-control/scripts/gen-chronology.js +3 -3
- package/mission-control/scripts/import-pipeline-v2.js +0 -16
- package/mission-control/scripts/import-pipeline.js +0 -15
- package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
- package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
- package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
- package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
- package/mission-control/src/app/api/cowork/events/route.ts +65 -0
- package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
- package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
- package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
- package/mission-control/src/app/api/diagnostics/route.ts +97 -0
- package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
- package/mission-control/src/app/api/mesh/events/route.ts +95 -19
- package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
- package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
- package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
- package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +1 -1
- package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
- package/mission-control/src/app/api/tasks/route.ts +21 -30
- package/mission-control/src/app/cowork/page.tsx +261 -0
- package/mission-control/src/app/diagnostics/page.tsx +385 -0
- package/mission-control/src/app/graph/page.tsx +26 -0
- package/mission-control/src/app/memory/page.tsx +1 -1
- package/mission-control/src/app/obsidian/page.tsx +36 -6
- package/mission-control/src/app/roadmap/page.tsx +24 -0
- package/mission-control/src/app/souls/page.tsx +2 -2
- package/mission-control/src/components/board/execution-config.tsx +431 -0
- package/mission-control/src/components/board/kanban-board.tsx +75 -9
- package/mission-control/src/components/board/kanban-column.tsx +135 -19
- package/mission-control/src/components/board/task-card.tsx +55 -2
- package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
- package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
- package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
- package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
- package/mission-control/src/components/cowork/role-picker.tsx +102 -0
- package/mission-control/src/components/cowork/session-card.tsx +284 -0
- package/mission-control/src/components/layout/sidebar.tsx +39 -2
- package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
- package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
- package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
- package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
- package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
- package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
- package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
- package/mission-control/src/lib/config.ts +58 -0
- package/mission-control/src/lib/db/index.ts +69 -0
- package/mission-control/src/lib/db/schema.ts +61 -3
- package/mission-control/src/lib/hooks.ts +309 -0
- package/mission-control/src/lib/memory/entities.ts +3 -2
- package/mission-control/src/lib/nats.ts +66 -1
- package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
- package/mission-control/src/lib/parsers/transcript.ts +4 -4
- package/mission-control/src/lib/scheduler.ts +12 -11
- package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
- package/mission-control/src/lib/sync/tasks.ts +23 -1
- package/mission-control/src/lib/task-id.ts +32 -0
- package/mission-control/src/lib/tts/index.ts +33 -9
- package/mission-control/tsconfig.json +2 -1
- package/mission-control/vitest.config.ts +14 -0
- package/package.json +15 -2
- package/services/service-manifest.json +1 -1
- package/skills/cc-godmode/references/agents.md +8 -8
- package/workspace-bin/memory-daemon.mjs +199 -5
- package/workspace-bin/session-search.mjs +204 -0
- package/workspace-bin/web-fetch.mjs +65 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* role-loader.js — Load, validate, and format role profiles for mesh tasks.
|
|
3
|
+
*
|
|
4
|
+
* Role profiles define:
|
|
5
|
+
* - responsibilities: what the agent SHOULD do (prompt injection)
|
|
6
|
+
* - must_not: what the agent must NOT do (prompt + post-validation)
|
|
7
|
+
* - framework: structured thinking scaffold (prompt injection)
|
|
8
|
+
* - required_outputs: post-completion structural validation
|
|
9
|
+
* - forbidden_patterns: post-completion negative validation
|
|
10
|
+
* - scope_paths: default scope if task doesn't specify one
|
|
11
|
+
* - escalation: failure routing map
|
|
12
|
+
*
|
|
13
|
+
* Roles live in config/roles/*.yaml (shipped) and ~/.openclaw/roles/ (user).
|
|
14
|
+
* Uses js-yaml for parsing (already a dependency via plan-templates).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const yaml = require('js-yaml');
|
|
20
|
+
|
|
21
|
+
// ── Role Loading ─────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load a single role profile from a YAML file.
|
|
25
|
+
*/
|
|
26
|
+
function loadRole(rolePath) {
|
|
27
|
+
const content = fs.readFileSync(rolePath, 'utf-8');
|
|
28
|
+
const role = yaml.load(content);
|
|
29
|
+
if (!role.id) role.id = path.basename(rolePath, '.yaml');
|
|
30
|
+
return role;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Find and load a role by ID, searching user dir first then shipped config.
|
|
35
|
+
* @param {string} roleId — e.g. "solidity-dev"
|
|
36
|
+
* @param {string[]} searchDirs — directories to search (first match wins)
|
|
37
|
+
* @returns {object|null} — role profile or null if not found
|
|
38
|
+
*/
|
|
39
|
+
function findRole(roleId, searchDirs) {
|
|
40
|
+
for (const dir of searchDirs) {
|
|
41
|
+
for (const ext of ['.yaml', '.yml']) {
|
|
42
|
+
const candidate = path.join(dir, `${roleId}${ext}`);
|
|
43
|
+
if (fs.existsSync(candidate)) {
|
|
44
|
+
try {
|
|
45
|
+
return loadRole(candidate);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error(`[role-loader] Failed to load ${candidate}: ${err.message}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* List all available roles across search directories.
|
|
57
|
+
* @returns {Array<{id, name, description, file}>}
|
|
58
|
+
*/
|
|
59
|
+
function listRoles(searchDirs) {
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
const roles = [];
|
|
62
|
+
|
|
63
|
+
for (const dir of searchDirs) {
|
|
64
|
+
if (!fs.existsSync(dir)) continue;
|
|
65
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
try {
|
|
68
|
+
const role = loadRole(path.join(dir, file));
|
|
69
|
+
if (!seen.has(role.id)) {
|
|
70
|
+
seen.add(role.id);
|
|
71
|
+
roles.push({
|
|
72
|
+
id: role.id,
|
|
73
|
+
name: role.name || role.id,
|
|
74
|
+
description: role.description || '',
|
|
75
|
+
file,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
} catch { /* skip malformed */ }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return roles;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Role Validation ──────────────────────────────────
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validate a role profile for structural correctness.
|
|
89
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
90
|
+
*/
|
|
91
|
+
function validateRole(role) {
|
|
92
|
+
const errors = [];
|
|
93
|
+
if (!role.id) errors.push('Missing role id');
|
|
94
|
+
if (role.responsibilities && !Array.isArray(role.responsibilities)) {
|
|
95
|
+
errors.push('responsibilities must be an array');
|
|
96
|
+
}
|
|
97
|
+
if (role.must_not && !Array.isArray(role.must_not)) {
|
|
98
|
+
errors.push('must_not must be an array');
|
|
99
|
+
}
|
|
100
|
+
if (role.required_outputs && !Array.isArray(role.required_outputs)) {
|
|
101
|
+
errors.push('required_outputs must be an array');
|
|
102
|
+
}
|
|
103
|
+
if (role.forbidden_patterns && !Array.isArray(role.forbidden_patterns)) {
|
|
104
|
+
errors.push('forbidden_patterns must be an array');
|
|
105
|
+
}
|
|
106
|
+
if (role.escalation && typeof role.escalation !== 'object') {
|
|
107
|
+
errors.push('escalation must be an object');
|
|
108
|
+
}
|
|
109
|
+
return { valid: errors.length === 0, errors };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Prompt Formatting ────────────────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Format a role profile into markdown for prompt injection.
|
|
116
|
+
* Injected between Scope and Instructions in the agent prompt.
|
|
117
|
+
* LLM-agnostic: standard markdown that any LLM can consume.
|
|
118
|
+
*/
|
|
119
|
+
function formatRoleForPrompt(role) {
|
|
120
|
+
if (!role) return '';
|
|
121
|
+
const parts = [];
|
|
122
|
+
|
|
123
|
+
parts.push(`## Role: ${role.name || role.id}`);
|
|
124
|
+
parts.push('');
|
|
125
|
+
|
|
126
|
+
if (role.responsibilities && role.responsibilities.length > 0) {
|
|
127
|
+
parts.push('### Responsibilities');
|
|
128
|
+
for (const r of role.responsibilities) {
|
|
129
|
+
parts.push(`- ${r}`);
|
|
130
|
+
}
|
|
131
|
+
parts.push('');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (role.must_not && role.must_not.length > 0) {
|
|
135
|
+
parts.push('### Boundaries (Must NOT Do)');
|
|
136
|
+
for (const m of role.must_not) {
|
|
137
|
+
parts.push(`- ❌ ${m}`);
|
|
138
|
+
}
|
|
139
|
+
parts.push('');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (role.framework) {
|
|
143
|
+
parts.push(`### Framework: ${role.framework.name}`);
|
|
144
|
+
parts.push(role.framework.prompt);
|
|
145
|
+
parts.push('');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return parts.join('\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Post-Completion Validation ───────────────────────
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Validate task output against role's required_outputs.
|
|
155
|
+
* @param {object} role — role profile
|
|
156
|
+
* @param {string[]} outputFiles — files created/modified by the task
|
|
157
|
+
* @param {string} worktreePath — path to task worktree
|
|
158
|
+
* @returns {{ passed: boolean, failures: Array<{type, description, detail}> }}
|
|
159
|
+
*/
|
|
160
|
+
function validateRequiredOutputs(role, outputFiles, worktreePath) {
|
|
161
|
+
if (!role || !role.required_outputs) return { passed: true, failures: [] };
|
|
162
|
+
|
|
163
|
+
const failures = [];
|
|
164
|
+
|
|
165
|
+
for (const req of role.required_outputs) {
|
|
166
|
+
if (req.type === 'file_match') {
|
|
167
|
+
// Check if any output file matches the pattern
|
|
168
|
+
const { globMatch } = require('./rule-loader');
|
|
169
|
+
const matched = outputFiles.some(f => globMatch(req.pattern, f));
|
|
170
|
+
if (!matched) {
|
|
171
|
+
failures.push({
|
|
172
|
+
type: 'file_match',
|
|
173
|
+
description: req.description,
|
|
174
|
+
detail: `No output file matches pattern: ${req.pattern}`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
} else if (req.type === 'content_check') {
|
|
178
|
+
// Check if files matching pattern contain required content
|
|
179
|
+
const { globMatch } = require('./rule-loader');
|
|
180
|
+
const matchingFiles = outputFiles.filter(f => globMatch(req.pattern, f));
|
|
181
|
+
if (matchingFiles.length > 0 && worktreePath) {
|
|
182
|
+
let found = false;
|
|
183
|
+
for (const file of matchingFiles) {
|
|
184
|
+
try {
|
|
185
|
+
const content = fs.readFileSync(path.join(worktreePath, file), 'utf-8');
|
|
186
|
+
if (content.includes(req.check)) {
|
|
187
|
+
found = true;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
} catch { /* file not readable */ }
|
|
191
|
+
}
|
|
192
|
+
if (!found) {
|
|
193
|
+
failures.push({
|
|
194
|
+
type: 'content_check',
|
|
195
|
+
description: req.description,
|
|
196
|
+
detail: `Required content "${req.check}" not found in ${req.pattern} files`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { passed: failures.length === 0, failures };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Check output against role's forbidden_patterns.
|
|
208
|
+
* @param {object} role — role profile
|
|
209
|
+
* @param {string[]} outputFiles — files created/modified
|
|
210
|
+
* @param {string} worktreePath — path to task worktree
|
|
211
|
+
* @returns {{ passed: boolean, violations: Array<{pattern, in, description, file, match}> }}
|
|
212
|
+
*/
|
|
213
|
+
function checkForbiddenPatterns(role, outputFiles, worktreePath) {
|
|
214
|
+
if (!role || !role.forbidden_patterns) return { passed: true, violations: [] };
|
|
215
|
+
|
|
216
|
+
const { globMatch } = require('./rule-loader');
|
|
217
|
+
const violations = [];
|
|
218
|
+
|
|
219
|
+
for (const fp of role.forbidden_patterns) {
|
|
220
|
+
const regex = new RegExp(fp.pattern, 'gm');
|
|
221
|
+
const scopeFiles = fp.in
|
|
222
|
+
? outputFiles.filter(f => globMatch(fp.in, f))
|
|
223
|
+
: outputFiles;
|
|
224
|
+
|
|
225
|
+
for (const file of scopeFiles) {
|
|
226
|
+
if (!worktreePath) continue;
|
|
227
|
+
try {
|
|
228
|
+
const content = fs.readFileSync(path.join(worktreePath, file), 'utf-8');
|
|
229
|
+
const matches = content.match(regex);
|
|
230
|
+
if (matches) {
|
|
231
|
+
violations.push({
|
|
232
|
+
pattern: fp.pattern,
|
|
233
|
+
in: fp.in,
|
|
234
|
+
description: fp.description,
|
|
235
|
+
file,
|
|
236
|
+
match: matches[0].slice(0, 100),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
} catch { /* skip unreadable */ }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { passed: violations.length === 0, violations };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Find the best-matching role for a set of task scope paths.
|
|
248
|
+
* Matches scope paths against each role's scope_paths field.
|
|
249
|
+
* Returns the role with the most scope path matches, or null.
|
|
250
|
+
*/
|
|
251
|
+
function findRoleByScope(scopePaths, searchDirs) {
|
|
252
|
+
if (!scopePaths || scopePaths.length === 0) return null;
|
|
253
|
+
|
|
254
|
+
const { globMatch } = require('./rule-loader');
|
|
255
|
+
const allRoles = listRoles(searchDirs);
|
|
256
|
+
let bestRole = null;
|
|
257
|
+
let bestScore = 0;
|
|
258
|
+
|
|
259
|
+
for (const roleSummary of allRoles) {
|
|
260
|
+
const role = findRole(roleSummary.id, searchDirs);
|
|
261
|
+
if (!role || !role.scope_paths) continue;
|
|
262
|
+
|
|
263
|
+
// Score: how many of the task's scope paths match this role's scope_paths?
|
|
264
|
+
let score = 0;
|
|
265
|
+
for (const taskPath of scopePaths) {
|
|
266
|
+
for (const rolePattern of role.scope_paths) {
|
|
267
|
+
if (globMatch(rolePattern, taskPath)) {
|
|
268
|
+
score++;
|
|
269
|
+
break; // one match per task path is enough
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (score > bestScore) {
|
|
275
|
+
bestScore = score;
|
|
276
|
+
bestRole = role;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return bestRole;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
module.exports = {
|
|
284
|
+
loadRole,
|
|
285
|
+
findRole,
|
|
286
|
+
findRoleByScope,
|
|
287
|
+
listRoles,
|
|
288
|
+
validateRole,
|
|
289
|
+
formatRoleForPrompt,
|
|
290
|
+
validateRequiredOutputs,
|
|
291
|
+
checkForbiddenPatterns,
|
|
292
|
+
};
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rule-loader.js — Load, match, and format path-scoped coding rules.
|
|
3
|
+
*
|
|
4
|
+
* Rules live in .openclaw/rules/ as markdown files with YAML frontmatter.
|
|
5
|
+
* This module is pure Node.js with zero external dependencies — importable
|
|
6
|
+
* by mesh-agent, install scripts, and any LLM adapter.
|
|
7
|
+
*
|
|
8
|
+
* Rule format:
|
|
9
|
+
* ---
|
|
10
|
+
* id: security
|
|
11
|
+
* tier: universal # universal | framework | project
|
|
12
|
+
* paths: ["**\/*"] # glob patterns for file matching
|
|
13
|
+
* detect: null # framework auto-activation signals
|
|
14
|
+
* priority: 100 # higher wins on conflict
|
|
15
|
+
* tags: ["security"]
|
|
16
|
+
* ---
|
|
17
|
+
* # Rule body in markdown
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
// ── Tier Precedence (higher = wins) ────────────────
|
|
24
|
+
const TIER_WEIGHT = {
|
|
25
|
+
universal: 0,
|
|
26
|
+
framework: 10,
|
|
27
|
+
project: 20,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ── Max chars for rule injection into prompts ──────
|
|
31
|
+
const MAX_RULES_CHARS = 4000;
|
|
32
|
+
|
|
33
|
+
// ── Frontmatter Parser (zero-dep) ──────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse YAML frontmatter from a markdown file.
|
|
37
|
+
* Handles: strings, arrays (inline [...] and block - items), numbers, booleans, null.
|
|
38
|
+
*/
|
|
39
|
+
function parseFrontmatter(content) {
|
|
40
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
41
|
+
if (!match) return { data: {}, body: content };
|
|
42
|
+
|
|
43
|
+
const yaml = match[1];
|
|
44
|
+
const body = content.slice(match[0].length).trim();
|
|
45
|
+
const data = {};
|
|
46
|
+
|
|
47
|
+
const lines = yaml.split('\n');
|
|
48
|
+
let currentKey = null;
|
|
49
|
+
let currentArray = null;
|
|
50
|
+
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
const trimmed = line.trim();
|
|
53
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
54
|
+
|
|
55
|
+
// Block array item: " - value"
|
|
56
|
+
if (trimmed.startsWith('- ') && currentKey && currentArray) {
|
|
57
|
+
currentArray.push(parseValue(trimmed.slice(2).trim()));
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Key-value pair
|
|
62
|
+
const kvMatch = trimmed.match(/^(\w[\w-]*):\s*(.*)/);
|
|
63
|
+
if (!kvMatch) continue;
|
|
64
|
+
|
|
65
|
+
// Save previous array if pending
|
|
66
|
+
if (currentKey && currentArray) {
|
|
67
|
+
data[currentKey] = currentArray;
|
|
68
|
+
currentArray = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const [, key, rawVal] = kvMatch;
|
|
72
|
+
currentKey = key;
|
|
73
|
+
|
|
74
|
+
if (rawVal === '' || rawVal === undefined) {
|
|
75
|
+
// Could be start of block array
|
|
76
|
+
currentArray = [];
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Inline array: ["a", "b"]
|
|
81
|
+
if (rawVal.startsWith('[')) {
|
|
82
|
+
try {
|
|
83
|
+
data[key] = JSON.parse(rawVal);
|
|
84
|
+
} catch {
|
|
85
|
+
data[key] = rawVal.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
|
|
86
|
+
}
|
|
87
|
+
currentArray = null;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
data[key] = parseValue(rawVal);
|
|
92
|
+
currentArray = null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Flush last pending array
|
|
96
|
+
if (currentKey && currentArray) {
|
|
97
|
+
data[currentKey] = currentArray;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { data, body };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseValue(val) {
|
|
104
|
+
if (val === 'null' || val === '~') return null;
|
|
105
|
+
if (val === 'true') return true;
|
|
106
|
+
if (val === 'false') return false;
|
|
107
|
+
if (/^-?\d+$/.test(val)) return parseInt(val, 10);
|
|
108
|
+
if (/^-?\d+\.\d+$/.test(val)) return parseFloat(val);
|
|
109
|
+
return val.replace(/^["']|["']$/g, '');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Glob Matching (zero-dep) ───────────────────────
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Match a file path against a glob pattern.
|
|
116
|
+
* Supports: *, **, ?, {a,b}
|
|
117
|
+
*/
|
|
118
|
+
function globMatch(pattern, filepath) {
|
|
119
|
+
// Normalize separators
|
|
120
|
+
const p = pattern.replace(/\\/g, '/');
|
|
121
|
+
const f = filepath.replace(/\\/g, '/');
|
|
122
|
+
|
|
123
|
+
// Convert glob to regex
|
|
124
|
+
let regex = '';
|
|
125
|
+
let i = 0;
|
|
126
|
+
while (i < p.length) {
|
|
127
|
+
const ch = p[i];
|
|
128
|
+
if (ch === '*') {
|
|
129
|
+
if (p[i + 1] === '*') {
|
|
130
|
+
// ** matches any number of path segments
|
|
131
|
+
if (p[i + 2] === '/') {
|
|
132
|
+
regex += '(?:.+/)?';
|
|
133
|
+
i += 3;
|
|
134
|
+
} else {
|
|
135
|
+
regex += '.*';
|
|
136
|
+
i += 2;
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// * matches anything except /
|
|
140
|
+
regex += '[^/]*';
|
|
141
|
+
i++;
|
|
142
|
+
}
|
|
143
|
+
} else if (ch === '?') {
|
|
144
|
+
regex += '[^/]';
|
|
145
|
+
i++;
|
|
146
|
+
} else if (ch === '{') {
|
|
147
|
+
const end = p.indexOf('}', i);
|
|
148
|
+
if (end === -1) { regex += '\\{'; i++; continue; }
|
|
149
|
+
const alts = p.slice(i + 1, end).split(',').map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
150
|
+
regex += `(?:${alts.join('|')})`;
|
|
151
|
+
i = end + 1;
|
|
152
|
+
} else {
|
|
153
|
+
regex += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
154
|
+
i++;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return new RegExp(`^${regex}$`).test(f);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Core API ───────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Load all rule files from a directory.
|
|
165
|
+
* Returns array of { id, tier, paths, detect, priority, tags, body, file }.
|
|
166
|
+
*/
|
|
167
|
+
function loadAllRules(rulesDir) {
|
|
168
|
+
if (!fs.existsSync(rulesDir)) return [];
|
|
169
|
+
|
|
170
|
+
const files = fs.readdirSync(rulesDir).filter(f => f.endsWith('.md'));
|
|
171
|
+
const rules = [];
|
|
172
|
+
|
|
173
|
+
for (const file of files) {
|
|
174
|
+
try {
|
|
175
|
+
const content = fs.readFileSync(path.join(rulesDir, file), 'utf-8');
|
|
176
|
+
const { data, body } = parseFrontmatter(content);
|
|
177
|
+
|
|
178
|
+
rules.push({
|
|
179
|
+
id: data.id || path.basename(file, '.md'),
|
|
180
|
+
tier: data.tier || 'universal',
|
|
181
|
+
paths: Array.isArray(data.paths) ? data.paths : ['**/*'],
|
|
182
|
+
detect: data.detect || null,
|
|
183
|
+
priority: parseInt(data.priority) || 50,
|
|
184
|
+
tags: Array.isArray(data.tags) ? data.tags : [],
|
|
185
|
+
body: body,
|
|
186
|
+
file: file,
|
|
187
|
+
});
|
|
188
|
+
} catch (err) {
|
|
189
|
+
// Skip malformed rule files silently
|
|
190
|
+
console.error(`[rule-loader] Skipped ${file}: ${err.message}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return rules;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Match rules against a set of scope paths.
|
|
199
|
+
* Returns rules sorted by: tier precedence (project > framework > universal),
|
|
200
|
+
* then priority (higher first).
|
|
201
|
+
*/
|
|
202
|
+
function matchRules(rules, scopePaths) {
|
|
203
|
+
if (!scopePaths || scopePaths.length === 0) return [];
|
|
204
|
+
|
|
205
|
+
const matched = rules.filter(rule => {
|
|
206
|
+
return rule.paths.some(pattern =>
|
|
207
|
+
scopePaths.some(scopePath => globMatch(pattern, scopePath))
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Sort: tier weight desc, then priority desc
|
|
212
|
+
matched.sort((a, b) => {
|
|
213
|
+
const tierDiff = (TIER_WEIGHT[b.tier] || 0) - (TIER_WEIGHT[a.tier] || 0);
|
|
214
|
+
if (tierDiff !== 0) return tierDiff;
|
|
215
|
+
return (b.priority || 0) - (a.priority || 0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return matched;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Format matched rules into a markdown block for prompt injection.
|
|
223
|
+
* Respects MAX_RULES_CHARS to avoid bloating prompts.
|
|
224
|
+
*/
|
|
225
|
+
function formatRulesForPrompt(matchedRules) {
|
|
226
|
+
if (!matchedRules || matchedRules.length === 0) return '';
|
|
227
|
+
|
|
228
|
+
const parts = ['## Coding Standards', ''];
|
|
229
|
+
let totalChars = parts[0].length;
|
|
230
|
+
|
|
231
|
+
for (const rule of matchedRules) {
|
|
232
|
+
const header = `### ${rule.id} (${rule.tier})`;
|
|
233
|
+
const section = `${header}\n${rule.body}`;
|
|
234
|
+
|
|
235
|
+
if (totalChars + section.length > MAX_RULES_CHARS) {
|
|
236
|
+
parts.push(`\n_[${matchedRules.length - parts.length + 2} more rules truncated — scope narrower to see all]_`);
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
parts.push(section);
|
|
241
|
+
parts.push('');
|
|
242
|
+
totalChars += section.length;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return parts.join('\n');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Detect frameworks present in a project directory.
|
|
250
|
+
* Returns array of framework identifiers (e.g., ['solidity', 'typescript']).
|
|
251
|
+
*/
|
|
252
|
+
function detectFrameworks(projectDir) {
|
|
253
|
+
const detected = [];
|
|
254
|
+
|
|
255
|
+
// Check package.json dependencies
|
|
256
|
+
const pkgPath = path.join(projectDir, 'package.json');
|
|
257
|
+
if (fs.existsSync(pkgPath)) {
|
|
258
|
+
try {
|
|
259
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
260
|
+
const allDeps = {
|
|
261
|
+
...(pkg.dependencies || {}),
|
|
262
|
+
...(pkg.devDependencies || {}),
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// Framework detection heuristics
|
|
266
|
+
if (allDeps.hardhat || allDeps['@nomicfoundation/hardhat-toolbox']) detected.push('solidity');
|
|
267
|
+
if (allDeps.react || allDeps['react-dom']) detected.push('react');
|
|
268
|
+
if (allDeps.next) detected.push('nextjs');
|
|
269
|
+
if (allDeps.vue) detected.push('vue');
|
|
270
|
+
if (allDeps.express || allDeps.fastify || allDeps.koa) detected.push('node-server');
|
|
271
|
+
} catch { /* skip malformed package.json */ }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Check config files
|
|
275
|
+
const configChecks = [
|
|
276
|
+
['tsconfig.json', 'typescript'],
|
|
277
|
+
['foundry.toml', 'solidity'],
|
|
278
|
+
['hardhat.config.js', 'solidity'],
|
|
279
|
+
['hardhat.config.ts', 'solidity'],
|
|
280
|
+
['Cargo.toml', 'rust'],
|
|
281
|
+
['go.mod', 'golang'],
|
|
282
|
+
['pyproject.toml', 'python'],
|
|
283
|
+
['requirements.txt', 'python'],
|
|
284
|
+
['Gemfile', 'ruby'],
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
for (const [file, framework] of configChecks) {
|
|
288
|
+
if (fs.existsSync(path.join(projectDir, file)) && !detected.includes(framework)) {
|
|
289
|
+
detected.push(framework);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Check directories
|
|
294
|
+
const dirChecks = [
|
|
295
|
+
['ProjectSettings', 'unity'], // Unity project
|
|
296
|
+
['Assets', 'unity'], // Unity assets
|
|
297
|
+
['.godot', 'godot'], // Godot
|
|
298
|
+
['ios', 'ios'], // iOS project
|
|
299
|
+
['android', 'android'], // Android project
|
|
300
|
+
];
|
|
301
|
+
|
|
302
|
+
for (const [dir, framework] of dirChecks) {
|
|
303
|
+
if (fs.existsSync(path.join(projectDir, dir)) && !detected.includes(framework)) {
|
|
304
|
+
detected.push(framework);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return detected;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Map config file patterns to the framework IDs that detectFrameworks() returns
|
|
312
|
+
const DETECT_SIGNAL_TO_FRAMEWORK = {
|
|
313
|
+
'hardhat.config.js': 'solidity',
|
|
314
|
+
'hardhat.config.ts': 'solidity',
|
|
315
|
+
'foundry.toml': 'solidity',
|
|
316
|
+
'tsconfig.json': 'typescript',
|
|
317
|
+
'Cargo.toml': 'rust',
|
|
318
|
+
'go.mod': 'golang',
|
|
319
|
+
'pyproject.toml': 'python',
|
|
320
|
+
'requirements.txt': 'python',
|
|
321
|
+
'Gemfile': 'ruby',
|
|
322
|
+
'ProjectSettings/ProjectVersion.txt': 'unity',
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Filter framework-tier rules to only those matching detected frameworks.
|
|
327
|
+
* Rules with detect: null are always included.
|
|
328
|
+
* Rules with detect: ["hardhat.config.js", "foundry.toml"] are included if
|
|
329
|
+
* any detect signal resolves to a detected framework ID.
|
|
330
|
+
*/
|
|
331
|
+
function activateFrameworkRules(rules, detectedFrameworks) {
|
|
332
|
+
return rules.filter(rule => {
|
|
333
|
+
if (rule.tier !== 'framework') return true; // non-framework rules pass through
|
|
334
|
+
if (!rule.detect) return true; // no detect = always active
|
|
335
|
+
|
|
336
|
+
const signals = Array.isArray(rule.detect) ? rule.detect : [rule.detect];
|
|
337
|
+
return signals.some(signal => {
|
|
338
|
+
// Direct framework ID match (e.g., detect: ["solidity"])
|
|
339
|
+
if (detectedFrameworks.includes(signal)) return true;
|
|
340
|
+
// Config file → framework ID resolution (e.g., "hardhat.config.js" → "solidity")
|
|
341
|
+
const resolvedFw = DETECT_SIGNAL_TO_FRAMEWORK[signal];
|
|
342
|
+
if (resolvedFw && detectedFrameworks.includes(resolvedFw)) return true;
|
|
343
|
+
return false;
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
module.exports = {
|
|
349
|
+
loadAllRules,
|
|
350
|
+
matchRules,
|
|
351
|
+
formatRulesForPrompt,
|
|
352
|
+
detectFrameworks,
|
|
353
|
+
activateFrameworkRules,
|
|
354
|
+
globMatch,
|
|
355
|
+
parseFrontmatter,
|
|
356
|
+
TIER_WEIGHT,
|
|
357
|
+
MAX_RULES_CHARS,
|
|
358
|
+
};
|