openclaw-node-harness 2.0.4 → 2.1.1
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/lane-watchdog.js +23 -2
- package/bin/mesh-agent.js +439 -28
- package/bin/mesh-bridge.js +69 -3
- package/bin/mesh-health-publisher.js +41 -1
- package/bin/mesh-task-daemon.js +821 -26
- package/bin/mesh.js +411 -20
- 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 +296 -10
- package/lib/agent-activity.js +2 -2
- package/lib/circling-parser.js +119 -0
- package/lib/exec-safety.js +105 -0
- package/lib/hyperagent-store.mjs +652 -0
- package/lib/kanban-io.js +24 -31
- package/lib/llm-providers.js +16 -0
- package/lib/mcp-knowledge/bench.mjs +118 -0
- package/lib/mcp-knowledge/core.mjs +530 -0
- package/lib/mcp-knowledge/package.json +25 -0
- package/lib/mcp-knowledge/server.mjs +252 -0
- package/lib/mcp-knowledge/test.mjs +802 -0
- package/lib/memory-budget.mjs +261 -0
- package/lib/mesh-collab.js +483 -165
- package/lib/mesh-harness.js +427 -0
- package/lib/mesh-plans.js +79 -50
- package/lib/mesh-tasks.js +132 -49
- package/lib/nats-resolve.js +4 -4
- package/lib/plan-templates.js +226 -0
- package/lib/pre-compression-flush.mjs +322 -0
- package/lib/role-loader.js +292 -0
- package/lib/rule-loader.js +358 -0
- package/lib/session-store.mjs +461 -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/memory/search/route.ts +6 -3
- 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/souls/[id]/evolution/route.ts +21 -5
- package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
- package/mission-control/src/app/api/souls/[id]/propagate/route.ts +14 -2
- package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +8 -2
- 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/api/workspace/read/route.ts +11 -0
- 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 +67 -0
- package/mission-control/src/lib/db/index.ts +85 -1
- 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/memory/extract.ts +2 -1
- package/mission-control/src/lib/memory/retrieval.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/src/middleware.ts +82 -0
- package/mission-control/tsconfig.json +2 -1
- package/mission-control/vitest.config.ts +14 -0
- package/package.json +15 -2
- package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
- package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
- package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
- package/services/launchd/ai.openclaw.mission-control.plist +1 -1
- package/services/service-manifest.json +1 -1
- package/skills/cc-godmode/references/agents.md +8 -8
- package/uninstall.sh +37 -9
- 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,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mesh-harness.js — Mechanical enforcement layer for mesh tasks.
|
|
3
|
+
*
|
|
4
|
+
* Two harnesses exist:
|
|
5
|
+
* LOCAL — prompt injection only (companion-bridge consumes these)
|
|
6
|
+
* MESH — mechanical enforcement at pre/post execution stages
|
|
7
|
+
*
|
|
8
|
+
* This module implements the mesh side. Each rule has a mesh_enforcement type:
|
|
9
|
+
* - "scope_check" → post-execution: revert files outside task.scope
|
|
10
|
+
* - "post_scan" → post-execution: scan LLM stdout for error patterns
|
|
11
|
+
* - "post_validate" → post-commit: run validation command
|
|
12
|
+
* - "pre_commit_scan" → pre-commit: scan staged diff for patterns
|
|
13
|
+
* - "output_block" → post-execution: scan output for blocked patterns
|
|
14
|
+
* - "metric_required" → post-execution: flag metric-less completions
|
|
15
|
+
* - "pre_check" → pre-execution: verify service health
|
|
16
|
+
*
|
|
17
|
+
* All enforcement is LLM-agnostic — it operates on filesystem state and
|
|
18
|
+
* process output, not on prompt compliance.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const { execSync } = require('child_process');
|
|
24
|
+
const { globMatch } = require('./rule-loader');
|
|
25
|
+
|
|
26
|
+
// ── Rule Loading ─────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load harness rules filtered by scope.
|
|
30
|
+
* @param {string} rulesPath — path to harness-rules.json
|
|
31
|
+
* @param {string} scope — "local" or "mesh"
|
|
32
|
+
* @returns {object[]} — active rules for this scope
|
|
33
|
+
*/
|
|
34
|
+
function loadHarnessRules(rulesPath, scope) {
|
|
35
|
+
if (!fs.existsSync(rulesPath)) return [];
|
|
36
|
+
try {
|
|
37
|
+
const rules = JSON.parse(fs.readFileSync(rulesPath, 'utf-8'));
|
|
38
|
+
return rules.filter(r =>
|
|
39
|
+
r.active !== false &&
|
|
40
|
+
Array.isArray(r.scope) &&
|
|
41
|
+
r.scope.includes(scope)
|
|
42
|
+
);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error(`[mesh-harness] Failed to load ${rulesPath}: ${err.message}`);
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get rules by mesh_enforcement type.
|
|
51
|
+
*/
|
|
52
|
+
function rulesByEnforcement(rules, type) {
|
|
53
|
+
return rules.filter(r => r.mesh_enforcement === type);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Enforcement: Scope Check ─────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Revert any files changed outside task.scope in a worktree.
|
|
60
|
+
* Returns { violations: string[], reverted: string[] }.
|
|
61
|
+
*/
|
|
62
|
+
function enforceScopeCheck(worktreePath, taskScope) {
|
|
63
|
+
if (!worktreePath || !taskScope || taskScope.length === 0) {
|
|
64
|
+
return { violations: [], reverted: [] };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const violations = [];
|
|
68
|
+
const reverted = [];
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const changed = execSync('git diff --name-only HEAD', {
|
|
72
|
+
cwd: worktreePath, timeout: 10000, encoding: 'utf-8',
|
|
73
|
+
}).trim();
|
|
74
|
+
|
|
75
|
+
// Also check untracked files
|
|
76
|
+
const untracked = execSync('git ls-files --others --exclude-standard', {
|
|
77
|
+
cwd: worktreePath, timeout: 10000, encoding: 'utf-8',
|
|
78
|
+
}).trim();
|
|
79
|
+
|
|
80
|
+
const allFiles = [...new Set([
|
|
81
|
+
...(changed ? changed.split('\n') : []),
|
|
82
|
+
...(untracked ? untracked.split('\n') : []),
|
|
83
|
+
])].filter(Boolean);
|
|
84
|
+
|
|
85
|
+
for (const file of allFiles) {
|
|
86
|
+
const inScope = taskScope.some(pattern => globMatch(pattern, file));
|
|
87
|
+
if (!inScope) {
|
|
88
|
+
violations.push(file);
|
|
89
|
+
try {
|
|
90
|
+
// Revert tracked files
|
|
91
|
+
execSync(`git checkout HEAD -- "${file}"`, {
|
|
92
|
+
cwd: worktreePath, timeout: 5000, stdio: 'pipe',
|
|
93
|
+
});
|
|
94
|
+
reverted.push(file);
|
|
95
|
+
} catch {
|
|
96
|
+
// Untracked file — remove it
|
|
97
|
+
try {
|
|
98
|
+
fs.unlinkSync(path.join(worktreePath, file));
|
|
99
|
+
reverted.push(file);
|
|
100
|
+
} catch { /* best effort */ }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.error(`[mesh-harness] Scope check error: ${err.message}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { violations, reverted };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Enforcement: Post-Execution Scan ─────────────────
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Scan LLM output for error patterns that suggest silent failure.
|
|
115
|
+
* Returns { suspicious: boolean, matches: string[] }.
|
|
116
|
+
*/
|
|
117
|
+
function postExecutionScan(llmOutput, scanPatterns) {
|
|
118
|
+
if (!llmOutput || !scanPatterns || scanPatterns.length === 0) {
|
|
119
|
+
return { suspicious: false, matches: [] };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const matches = [];
|
|
123
|
+
const lines = llmOutput.split('\n');
|
|
124
|
+
|
|
125
|
+
for (const line of lines) {
|
|
126
|
+
for (const pattern of scanPatterns) {
|
|
127
|
+
if (line.includes(pattern)) {
|
|
128
|
+
matches.push(line.trim().slice(0, 200));
|
|
129
|
+
break; // one match per line is enough
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Heuristic: if >20% of output lines contain error patterns, it's suspicious
|
|
135
|
+
// Also flag if any FAIL/PANIC/Traceback appears (high-confidence error signals)
|
|
136
|
+
const highConfidence = ['FAIL', 'PANIC', 'Traceback', 'FATAL'];
|
|
137
|
+
const hasHighConfidence = matches.some(m =>
|
|
138
|
+
highConfidence.some(hc => m.includes(hc))
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
suspicious: hasHighConfidence || matches.length > 3,
|
|
143
|
+
matches: matches.slice(0, 10), // cap at 10 matches
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Enforcement: Output Block ────────────────────────
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Scan LLM output for blocked patterns (destructive commands, etc.).
|
|
151
|
+
* Returns { blocked: boolean, violations: { ruleId, match }[] }.
|
|
152
|
+
*/
|
|
153
|
+
function scanOutputForBlocks(llmOutput, blockRules) {
|
|
154
|
+
if (!llmOutput) return { blocked: false, violations: [] };
|
|
155
|
+
|
|
156
|
+
const violations = [];
|
|
157
|
+
|
|
158
|
+
for (const rule of blockRules) {
|
|
159
|
+
if (!rule.pattern) continue;
|
|
160
|
+
try {
|
|
161
|
+
const regex = new RegExp(rule.pattern, 'gm');
|
|
162
|
+
const matches = llmOutput.match(regex);
|
|
163
|
+
if (matches) {
|
|
164
|
+
violations.push({
|
|
165
|
+
ruleId: rule.id,
|
|
166
|
+
pattern: rule.pattern,
|
|
167
|
+
matches: matches.slice(0, 5),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
} catch { /* skip invalid regex */ }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
blocked: violations.length > 0,
|
|
175
|
+
violations,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Enforcement: Pre-Commit Scan ─────────────────────
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Scan staged diff for secrets before committing.
|
|
183
|
+
* Returns { blocked: boolean, findings: string[] }.
|
|
184
|
+
*/
|
|
185
|
+
function preCommitSecretScan(worktreePath) {
|
|
186
|
+
if (!worktreePath) return { blocked: false, findings: [] };
|
|
187
|
+
|
|
188
|
+
const findings = [];
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
// Check if gitleaks is available
|
|
192
|
+
try {
|
|
193
|
+
const result = execSync('gitleaks detect --staged --no-banner 2>&1', {
|
|
194
|
+
cwd: worktreePath, timeout: 30000, encoding: 'utf-8',
|
|
195
|
+
});
|
|
196
|
+
if (/leaks?\s+found|secret|token/i.test(result)) {
|
|
197
|
+
findings.push(`gitleaks: ${result.trim().slice(0, 200)}`);
|
|
198
|
+
}
|
|
199
|
+
} catch (glErr) {
|
|
200
|
+
// gitleaks not available or found leaks (exit code 1)
|
|
201
|
+
if (glErr.stdout && /leaks?\s+found/i.test(glErr.stdout)) {
|
|
202
|
+
findings.push(`gitleaks: ${glErr.stdout.trim().slice(0, 200)}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Fallback: regex scan on staged diff
|
|
207
|
+
if (findings.length === 0) {
|
|
208
|
+
const diff = execSync('git diff --cached -U0 2>/dev/null', {
|
|
209
|
+
cwd: worktreePath, timeout: 10000, encoding: 'utf-8',
|
|
210
|
+
});
|
|
211
|
+
const secretPatterns = [
|
|
212
|
+
/^\+.*sk-[a-zA-Z0-9]{20,}/m,
|
|
213
|
+
/^\+.*AKIA[A-Z0-9]{16}/m,
|
|
214
|
+
/^\+.*password\s*=\s*["'][^"']+["']/im,
|
|
215
|
+
/^\+.*api_key\s*=\s*["'][^"']+["']/im,
|
|
216
|
+
/^\+.*secret\s*=\s*["'][^"']+["']/im,
|
|
217
|
+
];
|
|
218
|
+
for (const pat of secretPatterns) {
|
|
219
|
+
const match = diff.match(pat);
|
|
220
|
+
if (match) {
|
|
221
|
+
findings.push(`regex: ${match[0].trim().slice(0, 100)}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} catch (err) {
|
|
226
|
+
console.error(`[mesh-harness] Pre-commit scan error: ${err.message}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
blocked: findings.length > 0,
|
|
231
|
+
findings,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Enforcement: Post-Commit Validation ──────────────
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Run a validation command after commit (e.g., conventional commit check).
|
|
239
|
+
* Returns { passed: boolean, output: string }.
|
|
240
|
+
*/
|
|
241
|
+
function postCommitValidate(worktreePath, command) {
|
|
242
|
+
if (!worktreePath || !command) return { passed: true, output: '' };
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
const output = execSync(command, {
|
|
246
|
+
cwd: worktreePath, timeout: 10000, encoding: 'utf-8', stdio: 'pipe',
|
|
247
|
+
});
|
|
248
|
+
return { passed: true, output: output.trim() };
|
|
249
|
+
} catch (err) {
|
|
250
|
+
return {
|
|
251
|
+
passed: false,
|
|
252
|
+
output: (err.stdout || err.stderr || err.message || '').trim().slice(0, 500),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Composite: Run All Mesh Enforcement ──────────────
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Run the full mesh harness enforcement suite for a completed task.
|
|
261
|
+
* Called after LLM exits, before commitAndMergeWorktree.
|
|
262
|
+
*
|
|
263
|
+
* @param {object} opts
|
|
264
|
+
* @param {object[]} opts.rules — loaded mesh harness rules
|
|
265
|
+
* @param {string} opts.worktreePath — task worktree
|
|
266
|
+
* @param {string[]} opts.taskScope — task.scope glob patterns
|
|
267
|
+
* @param {string} opts.llmOutput — LLM stdout
|
|
268
|
+
* @param {boolean} opts.hasMetric — whether task has a metric
|
|
269
|
+
* @param {function} opts.log — logging function
|
|
270
|
+
* @returns {object} — { pass, violations, warnings }
|
|
271
|
+
*/
|
|
272
|
+
function runMeshHarness(opts) {
|
|
273
|
+
const { rules, worktreePath, taskScope, llmOutput, hasMetric, log, role } = opts;
|
|
274
|
+
const violations = [];
|
|
275
|
+
const warnings = [];
|
|
276
|
+
|
|
277
|
+
// 1. Scope enforcement
|
|
278
|
+
const scopeRules = rulesByEnforcement(rules, 'scope_check');
|
|
279
|
+
if (scopeRules.length > 0 && taskScope && taskScope.length > 0) {
|
|
280
|
+
const result = enforceScopeCheck(worktreePath, taskScope);
|
|
281
|
+
if (result.violations.length > 0) {
|
|
282
|
+
const msg = `SCOPE VIOLATION: ${result.violations.length} file(s) outside scope reverted: ${result.reverted.join(', ')}`;
|
|
283
|
+
violations.push({ rule: 'scope-enforcement', message: msg, files: result.violations });
|
|
284
|
+
log(`[HARNESS] ${msg}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 1.5. Forbidden pattern check (from role profile, runs on worktree files)
|
|
289
|
+
if (role && role.forbidden_patterns && worktreePath) {
|
|
290
|
+
const { checkForbiddenPatterns } = require('./role-loader');
|
|
291
|
+
// Get list of changed files in worktree (post-scope-revert)
|
|
292
|
+
try {
|
|
293
|
+
const { execSync } = require('child_process');
|
|
294
|
+
const changed = execSync('git diff --name-only HEAD', {
|
|
295
|
+
cwd: worktreePath, timeout: 10000, encoding: 'utf-8',
|
|
296
|
+
}).trim();
|
|
297
|
+
const untracked = execSync('git ls-files --others --exclude-standard', {
|
|
298
|
+
cwd: worktreePath, timeout: 10000, encoding: 'utf-8',
|
|
299
|
+
}).trim();
|
|
300
|
+
const outputFiles = [...new Set([
|
|
301
|
+
...(changed ? changed.split('\n') : []),
|
|
302
|
+
...(untracked ? untracked.split('\n') : []),
|
|
303
|
+
])].filter(Boolean);
|
|
304
|
+
|
|
305
|
+
if (outputFiles.length > 0) {
|
|
306
|
+
const fpResult = checkForbiddenPatterns(role, outputFiles, worktreePath);
|
|
307
|
+
if (!fpResult.passed) {
|
|
308
|
+
for (const v of fpResult.violations) {
|
|
309
|
+
const msg = `FORBIDDEN PATTERN: "${v.description}" in ${v.file} (matched: ${v.match})`;
|
|
310
|
+
violations.push({ rule: `forbidden:${v.pattern}`, message: msg, file: v.file });
|
|
311
|
+
log(`[HARNESS] ${msg}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} catch (err) {
|
|
316
|
+
log(`[HARNESS] Forbidden pattern check error: ${err.message}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 2. Output block scan
|
|
321
|
+
const blockRules = rulesByEnforcement(rules, 'output_block');
|
|
322
|
+
if (blockRules.length > 0) {
|
|
323
|
+
const result = scanOutputForBlocks(llmOutput, blockRules);
|
|
324
|
+
if (result.blocked) {
|
|
325
|
+
for (const v of result.violations) {
|
|
326
|
+
const msg = `OUTPUT BLOCK: rule "${v.ruleId}" matched pattern /${v.pattern}/ (${v.matches.length} occurrence(s))`;
|
|
327
|
+
violations.push({ rule: v.ruleId, message: msg });
|
|
328
|
+
log(`[HARNESS] ${msg}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 3. Post-execution error scan (for metric-less tasks only)
|
|
334
|
+
const scanRules = rulesByEnforcement(rules, 'post_scan');
|
|
335
|
+
if (scanRules.length > 0 && !hasMetric) {
|
|
336
|
+
for (const rule of scanRules) {
|
|
337
|
+
const result = postExecutionScan(llmOutput, rule.mesh_scan_patterns);
|
|
338
|
+
if (result.suspicious) {
|
|
339
|
+
const msg = `SUSPICIOUS OUTPUT: ${result.matches.length} error-like patterns found in output (no metric to verify)`;
|
|
340
|
+
warnings.push({ rule: rule.id, message: msg, matches: result.matches });
|
|
341
|
+
log(`[HARNESS] ${msg}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 4. Metric-required flag
|
|
347
|
+
const metricRules = rulesByEnforcement(rules, 'metric_required');
|
|
348
|
+
if (metricRules.length > 0 && !hasMetric) {
|
|
349
|
+
warnings.push({
|
|
350
|
+
rule: 'build-before-done',
|
|
351
|
+
message: 'Task completed without metric — no mechanical verification of success',
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 5. Pre-commit secret scan
|
|
356
|
+
const secretRules = rulesByEnforcement(rules, 'pre_commit_scan');
|
|
357
|
+
if (secretRules.length > 0 && worktreePath) {
|
|
358
|
+
const result = preCommitSecretScan(worktreePath);
|
|
359
|
+
if (result.blocked) {
|
|
360
|
+
const msg = `SECRET DETECTED: ${result.findings.join('; ')}`;
|
|
361
|
+
violations.push({ rule: 'no-hardcoded-secrets', message: msg, findings: result.findings });
|
|
362
|
+
log(`[HARNESS] ${msg}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const pass = violations.length === 0;
|
|
367
|
+
|
|
368
|
+
return { pass, violations, warnings };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Run post-commit validation checks.
|
|
373
|
+
* Called after commitAndMergeWorktree, before reporting completion.
|
|
374
|
+
*
|
|
375
|
+
* @param {object[]} rules — loaded mesh harness rules
|
|
376
|
+
* @param {string} worktreePath
|
|
377
|
+
* @param {function} log
|
|
378
|
+
* @returns {object[]} — array of { rule, passed, output } for each validation
|
|
379
|
+
*/
|
|
380
|
+
function runPostCommitValidation(rules, worktreePath, log) {
|
|
381
|
+
const results = [];
|
|
382
|
+
const validateRules = rulesByEnforcement(rules, 'post_validate');
|
|
383
|
+
|
|
384
|
+
for (const rule of validateRules) {
|
|
385
|
+
if (!rule.mesh_validate_command) continue;
|
|
386
|
+
const result = postCommitValidate(worktreePath, rule.mesh_validate_command);
|
|
387
|
+
results.push({
|
|
388
|
+
rule: rule.id,
|
|
389
|
+
passed: result.passed,
|
|
390
|
+
output: result.output,
|
|
391
|
+
});
|
|
392
|
+
if (!result.passed) {
|
|
393
|
+
log(`[HARNESS] POST-COMMIT FAIL: ${rule.id} — ${result.output}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return results;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Get inject-type rules for prompt injection (soft enforcement layer).
|
|
402
|
+
* These are injected into the LLM prompt in addition to mechanical enforcement.
|
|
403
|
+
* LLM-agnostic: returns markdown text that any LLM can consume.
|
|
404
|
+
*/
|
|
405
|
+
function formatHarnessForPrompt(rules) {
|
|
406
|
+
const injectRules = rules.filter(r => r.type === 'inject' && r.content);
|
|
407
|
+
if (injectRules.length === 0) return '';
|
|
408
|
+
|
|
409
|
+
const parts = ['## Harness Rules', ''];
|
|
410
|
+
for (const rule of injectRules) {
|
|
411
|
+
parts.push(rule.content);
|
|
412
|
+
}
|
|
413
|
+
return parts.join('\n');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
module.exports = {
|
|
417
|
+
loadHarnessRules,
|
|
418
|
+
rulesByEnforcement,
|
|
419
|
+
enforceScopeCheck,
|
|
420
|
+
postExecutionScan,
|
|
421
|
+
scanOutputForBlocks,
|
|
422
|
+
preCommitSecretScan,
|
|
423
|
+
postCommitValidate,
|
|
424
|
+
runMeshHarness,
|
|
425
|
+
runPostCommitValidation,
|
|
426
|
+
formatHarnessForPrompt,
|
|
427
|
+
};
|
package/lib/mesh-plans.js
CHANGED
|
@@ -27,9 +27,10 @@ const PLAN_STATUS = {
|
|
|
27
27
|
// ── Subtask Statuses ───────────────────────────────
|
|
28
28
|
|
|
29
29
|
const SUBTASK_STATUS = {
|
|
30
|
-
PENDING: 'pending',
|
|
31
|
-
QUEUED: 'queued',
|
|
32
|
-
RUNNING: 'running',
|
|
30
|
+
PENDING: 'pending', // not yet dispatched (waiting for wave)
|
|
31
|
+
QUEUED: 'queued', // dispatched to queue
|
|
32
|
+
RUNNING: 'running', // actively being worked
|
|
33
|
+
PENDING_REVIEW: 'pending_review', // work done, awaiting human approval
|
|
33
34
|
COMPLETED: 'completed',
|
|
34
35
|
FAILED: 'failed',
|
|
35
36
|
BLOCKED: 'blocked',
|
|
@@ -83,6 +84,7 @@ function createPlan({
|
|
|
83
84
|
planner = 'daedalus',
|
|
84
85
|
planner_soul = null,
|
|
85
86
|
requires_approval = true,
|
|
87
|
+
failure_policy = 'continue_best_effort', // 'continue_best_effort' | 'abort_on_first_fail' | 'abort_on_critical_fail'
|
|
86
88
|
subtasks = [],
|
|
87
89
|
}) {
|
|
88
90
|
const planId = `PLAN-${parent_task_id}-${Date.now()}`;
|
|
@@ -109,6 +111,8 @@ function createPlan({
|
|
|
109
111
|
scope: st.scope || [],
|
|
110
112
|
success_criteria: st.success_criteria || [],
|
|
111
113
|
|
|
114
|
+
critical: st.critical || false, // critical subtask failure can abort plan (abort_on_critical_fail policy)
|
|
115
|
+
|
|
112
116
|
depends_on: st.depends_on || [],
|
|
113
117
|
wave: 0, // computed below
|
|
114
118
|
|
|
@@ -142,6 +146,7 @@ function createPlan({
|
|
|
142
146
|
total_budget_minutes: totalBudget,
|
|
143
147
|
estimated_waves: maxWave + 1,
|
|
144
148
|
|
|
149
|
+
failure_policy,
|
|
145
150
|
requires_approval,
|
|
146
151
|
approved_by: null,
|
|
147
152
|
approved_at: null,
|
|
@@ -205,6 +210,14 @@ function assignWaves(subtasks) {
|
|
|
205
210
|
wave++;
|
|
206
211
|
currentWave = nextWave;
|
|
207
212
|
}
|
|
213
|
+
|
|
214
|
+
// Detect cycles: any node with remaining in-degree > 0 is in a cycle
|
|
215
|
+
for (const [taskId, degree] of inDegree.entries()) {
|
|
216
|
+
if (degree > 0) {
|
|
217
|
+
const subtask = idMap.get(taskId);
|
|
218
|
+
if (subtask) subtask.wave = -1; // blocked by cycle
|
|
219
|
+
}
|
|
220
|
+
}
|
|
208
221
|
}
|
|
209
222
|
|
|
210
223
|
// ── Delegation Decision Tree ───────────────────────
|
|
@@ -314,12 +327,15 @@ function routeDelegation(subtask) {
|
|
|
314
327
|
|
|
315
328
|
/**
|
|
316
329
|
* Auto-route all subtasks in a plan that don't already have delegation set.
|
|
317
|
-
* Mutates subtasks in place.
|
|
330
|
+
* Mutates subtasks in place. Each routing decision is logged to the subtask's
|
|
331
|
+
* delegation.reason field for inspection via `mesh plan show`.
|
|
318
332
|
*/
|
|
319
|
-
function autoRoutePlan(plan) {
|
|
333
|
+
function autoRoutePlan(plan, { log } = {}) {
|
|
334
|
+
const logger = log || (() => {});
|
|
320
335
|
for (const st of plan.subtasks) {
|
|
321
336
|
if (!st.delegation || !st.delegation.mode || st.delegation.mode === 'auto') {
|
|
322
337
|
st.delegation = routeDelegation(st);
|
|
338
|
+
logger(`AUTO-ROUTE ${st.subtask_id} → ${st.delegation.mode}: ${st.delegation.reason}`);
|
|
323
339
|
}
|
|
324
340
|
}
|
|
325
341
|
return plan;
|
|
@@ -343,6 +359,28 @@ class PlanStore {
|
|
|
343
359
|
return JSON.parse(sc.decode(entry.value));
|
|
344
360
|
}
|
|
345
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Compare-and-swap helper: read → mutate → write with optimistic concurrency.
|
|
364
|
+
* Re-reads and retries on conflict (up to maxRetries).
|
|
365
|
+
* mutateFn receives the parsed data and must return the updated object, or falsy to skip.
|
|
366
|
+
*/
|
|
367
|
+
async _updateWithCAS(key, mutateFn, maxRetries = 3) {
|
|
368
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
369
|
+
const entry = await this.kv.get(key);
|
|
370
|
+
if (!entry) return null;
|
|
371
|
+
const data = JSON.parse(sc.decode(entry.value));
|
|
372
|
+
const updated = mutateFn(data);
|
|
373
|
+
if (!updated) return null;
|
|
374
|
+
try {
|
|
375
|
+
await this.kv.put(key, sc.encode(JSON.stringify(updated)), { previousSeq: entry.revision });
|
|
376
|
+
return updated;
|
|
377
|
+
} catch (err) {
|
|
378
|
+
if (attempt === maxRetries - 1) throw err;
|
|
379
|
+
// conflict — retry
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
346
384
|
async delete(planId) {
|
|
347
385
|
await this.kv.delete(planId);
|
|
348
386
|
}
|
|
@@ -381,69 +419,60 @@ class PlanStore {
|
|
|
381
419
|
// ── Lifecycle ───────────────────────────────────
|
|
382
420
|
|
|
383
421
|
async submitForReview(planId) {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
return plan;
|
|
422
|
+
return this._updateWithCAS(planId, (plan) => {
|
|
423
|
+
plan.status = PLAN_STATUS.REVIEW;
|
|
424
|
+
return plan;
|
|
425
|
+
});
|
|
389
426
|
}
|
|
390
427
|
|
|
391
428
|
async approve(planId, approvedBy = 'gui') {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
return plan;
|
|
429
|
+
return this._updateWithCAS(planId, (plan) => {
|
|
430
|
+
plan.status = PLAN_STATUS.APPROVED;
|
|
431
|
+
plan.approved_by = approvedBy;
|
|
432
|
+
plan.approved_at = new Date().toISOString();
|
|
433
|
+
return plan;
|
|
434
|
+
});
|
|
399
435
|
}
|
|
400
436
|
|
|
401
437
|
async startExecuting(planId) {
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
return plan;
|
|
438
|
+
return this._updateWithCAS(planId, (plan) => {
|
|
439
|
+
plan.status = PLAN_STATUS.EXECUTING;
|
|
440
|
+
plan.started_at = new Date().toISOString();
|
|
441
|
+
return plan;
|
|
442
|
+
});
|
|
408
443
|
}
|
|
409
444
|
|
|
410
445
|
async markCompleted(planId) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
return plan;
|
|
446
|
+
return this._updateWithCAS(planId, (plan) => {
|
|
447
|
+
plan.status = PLAN_STATUS.COMPLETED;
|
|
448
|
+
plan.completed_at = new Date().toISOString();
|
|
449
|
+
return plan;
|
|
450
|
+
});
|
|
417
451
|
}
|
|
418
452
|
|
|
419
453
|
async markAborted(planId, reason) {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
st.result = { success: false, summary: `Plan aborted: ${reason}` };
|
|
454
|
+
return this._updateWithCAS(planId, (plan) => {
|
|
455
|
+
plan.status = PLAN_STATUS.ABORTED;
|
|
456
|
+
plan.completed_at = new Date().toISOString();
|
|
457
|
+
for (const st of plan.subtasks) {
|
|
458
|
+
if (st.status === SUBTASK_STATUS.PENDING || st.status === SUBTASK_STATUS.QUEUED) {
|
|
459
|
+
st.status = SUBTASK_STATUS.BLOCKED;
|
|
460
|
+
st.result = { success: false, summary: `Plan aborted: ${reason}` };
|
|
461
|
+
}
|
|
429
462
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
return plan;
|
|
463
|
+
return plan;
|
|
464
|
+
});
|
|
433
465
|
}
|
|
434
466
|
|
|
435
467
|
// ── Subtask Management ──────────────────────────
|
|
436
468
|
|
|
437
469
|
async updateSubtask(planId, subtaskId, updates) {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
Object.assign(st, updates);
|
|
445
|
-
await this.put(plan);
|
|
446
|
-
return plan;
|
|
470
|
+
return this._updateWithCAS(planId, (plan) => {
|
|
471
|
+
const st = plan.subtasks.find(s => s.subtask_id === subtaskId);
|
|
472
|
+
if (!st) return null;
|
|
473
|
+
Object.assign(st, updates);
|
|
474
|
+
return plan;
|
|
475
|
+
});
|
|
447
476
|
}
|
|
448
477
|
|
|
449
478
|
/**
|