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.
Files changed (134) hide show
  1. package/README.md +646 -3
  2. package/bin/hyperagent.mjs +419 -0
  3. package/bin/lane-watchdog.js +23 -2
  4. package/bin/mesh-agent.js +439 -28
  5. package/bin/mesh-bridge.js +69 -3
  6. package/bin/mesh-health-publisher.js +41 -1
  7. package/bin/mesh-task-daemon.js +821 -26
  8. package/bin/mesh.js +411 -20
  9. package/config/claude-settings.json +95 -0
  10. package/config/daemon.json.template +2 -1
  11. package/config/git-hooks/pre-commit +13 -0
  12. package/config/git-hooks/pre-push +12 -0
  13. package/config/harness-rules.json +174 -0
  14. package/config/plan-templates/team-bugfix.yaml +52 -0
  15. package/config/plan-templates/team-deploy.yaml +50 -0
  16. package/config/plan-templates/team-feature.yaml +71 -0
  17. package/config/roles/qa-engineer.yaml +36 -0
  18. package/config/roles/solidity-dev.yaml +51 -0
  19. package/config/roles/tech-architect.yaml +36 -0
  20. package/config/rules/framework/solidity.md +22 -0
  21. package/config/rules/framework/typescript.md +21 -0
  22. package/config/rules/framework/unity.md +21 -0
  23. package/config/rules/universal/design-docs.md +18 -0
  24. package/config/rules/universal/git-hygiene.md +18 -0
  25. package/config/rules/universal/security.md +19 -0
  26. package/config/rules/universal/test-standards.md +19 -0
  27. package/identity/DELEGATION.md +6 -6
  28. package/install.sh +296 -10
  29. package/lib/agent-activity.js +2 -2
  30. package/lib/circling-parser.js +119 -0
  31. package/lib/exec-safety.js +105 -0
  32. package/lib/hyperagent-store.mjs +652 -0
  33. package/lib/kanban-io.js +24 -31
  34. package/lib/llm-providers.js +16 -0
  35. package/lib/mcp-knowledge/bench.mjs +118 -0
  36. package/lib/mcp-knowledge/core.mjs +530 -0
  37. package/lib/mcp-knowledge/package.json +25 -0
  38. package/lib/mcp-knowledge/server.mjs +252 -0
  39. package/lib/mcp-knowledge/test.mjs +802 -0
  40. package/lib/memory-budget.mjs +261 -0
  41. package/lib/mesh-collab.js +483 -165
  42. package/lib/mesh-harness.js +427 -0
  43. package/lib/mesh-plans.js +79 -50
  44. package/lib/mesh-tasks.js +132 -49
  45. package/lib/nats-resolve.js +4 -4
  46. package/lib/plan-templates.js +226 -0
  47. package/lib/pre-compression-flush.mjs +322 -0
  48. package/lib/role-loader.js +292 -0
  49. package/lib/rule-loader.js +358 -0
  50. package/lib/session-store.mjs +461 -0
  51. package/lib/transcript-parser.mjs +292 -0
  52. package/mission-control/drizzle/soul_schema_update.sql +29 -0
  53. package/mission-control/drizzle.config.ts +1 -4
  54. package/mission-control/package-lock.json +1571 -83
  55. package/mission-control/package.json +6 -2
  56. package/mission-control/scripts/gen-chronology.js +3 -3
  57. package/mission-control/scripts/import-pipeline-v2.js +0 -16
  58. package/mission-control/scripts/import-pipeline.js +0 -15
  59. package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
  60. package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
  61. package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
  62. package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
  63. package/mission-control/src/app/api/cowork/events/route.ts +65 -0
  64. package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
  65. package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
  66. package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
  67. package/mission-control/src/app/api/diagnostics/route.ts +97 -0
  68. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
  69. package/mission-control/src/app/api/memory/search/route.ts +6 -3
  70. package/mission-control/src/app/api/mesh/events/route.ts +95 -19
  71. package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
  72. package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
  73. package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
  74. package/mission-control/src/app/api/souls/[id]/evolution/route.ts +21 -5
  75. package/mission-control/src/app/api/souls/[id]/prompt/route.ts +7 -1
  76. package/mission-control/src/app/api/souls/[id]/propagate/route.ts +14 -2
  77. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +8 -2
  78. package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
  79. package/mission-control/src/app/api/tasks/route.ts +21 -30
  80. package/mission-control/src/app/api/workspace/read/route.ts +11 -0
  81. package/mission-control/src/app/cowork/page.tsx +261 -0
  82. package/mission-control/src/app/diagnostics/page.tsx +385 -0
  83. package/mission-control/src/app/graph/page.tsx +26 -0
  84. package/mission-control/src/app/memory/page.tsx +1 -1
  85. package/mission-control/src/app/obsidian/page.tsx +36 -6
  86. package/mission-control/src/app/roadmap/page.tsx +24 -0
  87. package/mission-control/src/app/souls/page.tsx +2 -2
  88. package/mission-control/src/components/board/execution-config.tsx +431 -0
  89. package/mission-control/src/components/board/kanban-board.tsx +75 -9
  90. package/mission-control/src/components/board/kanban-column.tsx +135 -19
  91. package/mission-control/src/components/board/task-card.tsx +55 -2
  92. package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
  93. package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
  94. package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
  95. package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
  96. package/mission-control/src/components/cowork/role-picker.tsx +102 -0
  97. package/mission-control/src/components/cowork/session-card.tsx +284 -0
  98. package/mission-control/src/components/layout/sidebar.tsx +39 -2
  99. package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
  100. package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
  101. package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
  102. package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
  103. package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
  104. package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
  105. package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
  106. package/mission-control/src/lib/config.ts +67 -0
  107. package/mission-control/src/lib/db/index.ts +85 -1
  108. package/mission-control/src/lib/db/schema.ts +61 -3
  109. package/mission-control/src/lib/hooks.ts +309 -0
  110. package/mission-control/src/lib/memory/entities.ts +3 -2
  111. package/mission-control/src/lib/memory/extract.ts +2 -1
  112. package/mission-control/src/lib/memory/retrieval.ts +3 -2
  113. package/mission-control/src/lib/nats.ts +66 -1
  114. package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
  115. package/mission-control/src/lib/parsers/transcript.ts +4 -4
  116. package/mission-control/src/lib/scheduler.ts +12 -11
  117. package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
  118. package/mission-control/src/lib/sync/tasks.ts +23 -1
  119. package/mission-control/src/lib/task-id.ts +32 -0
  120. package/mission-control/src/lib/tts/index.ts +33 -9
  121. package/mission-control/src/middleware.ts +82 -0
  122. package/mission-control/tsconfig.json +2 -1
  123. package/mission-control/vitest.config.ts +14 -0
  124. package/package.json +15 -2
  125. package/services/launchd/ai.openclaw.log-rotate.plist +11 -0
  126. package/services/launchd/ai.openclaw.mesh-deploy-listener.plist +4 -0
  127. package/services/launchd/ai.openclaw.mesh-health-publisher.plist +4 -0
  128. package/services/launchd/ai.openclaw.mission-control.plist +1 -1
  129. package/services/service-manifest.json +1 -1
  130. package/skills/cc-godmode/references/agents.md +8 -8
  131. package/uninstall.sh +37 -9
  132. package/workspace-bin/memory-daemon.mjs +199 -5
  133. package/workspace-bin/session-search.mjs +204 -0
  134. 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', // not yet dispatched (waiting for wave)
31
- QUEUED: 'queued', // dispatched to queue
32
- RUNNING: 'running', // actively being worked
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
- const plan = await this.get(planId);
385
- if (!plan) return null;
386
- plan.status = PLAN_STATUS.REVIEW;
387
- await this.put(plan);
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
- const plan = await this.get(planId);
393
- if (!plan) return null;
394
- plan.status = PLAN_STATUS.APPROVED;
395
- plan.approved_by = approvedBy;
396
- plan.approved_at = new Date().toISOString();
397
- await this.put(plan);
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
- const plan = await this.get(planId);
403
- if (!plan) return null;
404
- plan.status = PLAN_STATUS.EXECUTING;
405
- plan.started_at = new Date().toISOString();
406
- await this.put(plan);
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
- const plan = await this.get(planId);
412
- if (!plan) return null;
413
- plan.status = PLAN_STATUS.COMPLETED;
414
- plan.completed_at = new Date().toISOString();
415
- await this.put(plan);
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
- const plan = await this.get(planId);
421
- if (!plan) return null;
422
- plan.status = PLAN_STATUS.ABORTED;
423
- plan.completed_at = new Date().toISOString();
424
- // Mark all pending subtasks as blocked
425
- for (const st of plan.subtasks) {
426
- if (st.status === SUBTASK_STATUS.PENDING || st.status === SUBTASK_STATUS.QUEUED) {
427
- st.status = SUBTASK_STATUS.BLOCKED;
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
- await this.put(plan);
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
- const plan = await this.get(planId);
439
- if (!plan) return null;
440
-
441
- const st = plan.subtasks.find(s => s.subtask_id === subtaskId);
442
- if (!st) return null;
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
  /**