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
package/bin/mesh-agent.js CHANGED
@@ -36,14 +36,17 @@
36
36
  */
37
37
 
38
38
  const { connect, StringCodec } = require('nats');
39
- const { spawn, execSync } = require('child_process');
39
+ const { spawn, execSync, execFileSync } = require('child_process');
40
40
  const os = require('os');
41
41
  const path = require('path');
42
42
  const fs = require('fs');
43
43
  const { getActivityState, getSessionInfo } = require('../lib/agent-activity');
44
+ const { loadAllRules, matchRules, formatRulesForPrompt, detectFrameworks, activateFrameworkRules } = require('../lib/rule-loader');
45
+ const { loadHarnessRules, runMeshHarness, runPostCommitValidation, formatHarnessForPrompt } = require('../lib/mesh-harness');
46
+ const { findRole, formatRoleForPrompt } = require('../lib/role-loader');
44
47
 
45
48
  const sc = StringCodec();
46
- const { NATS_URL } = require('../lib/nats-resolve');
49
+ const { NATS_URL, natsConnectOpts } = require('../lib/nats-resolve');
47
50
  const { resolveProvider, resolveModel } = require('../lib/llm-providers');
48
51
  const NODE_ID = process.env.MESH_NODE_ID || os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-');
49
52
  const POLL_INTERVAL = parseInt(process.env.MESH_POLL_INTERVAL || '15000'); // 15s between polls
@@ -51,6 +54,75 @@ const MAX_ATTEMPTS = parseInt(process.env.MESH_MAX_ATTEMPTS || '3');
51
54
  const HEARTBEAT_INTERVAL = parseInt(process.env.MESH_HEARTBEAT_INTERVAL || '60000'); // 60s heartbeat
52
55
  const WORKSPACE = process.env.MESH_WORKSPACE || path.join(process.env.HOME, '.openclaw', 'workspace');
53
56
 
57
+ // ── Rule loading (cached at startup, framework-filtered) ─────────────────
58
+ const RULES_DIR = process.env.OPENCLAW_RULES_DIR || path.join(process.env.HOME, '.openclaw', 'rules');
59
+ const HARNESS_PATH = process.env.OPENCLAW_HARNESS_RULES || path.join(process.env.HOME, '.openclaw', 'harness-rules.json');
60
+ let cachedRules = null;
61
+ let cachedHarnessRules = null;
62
+
63
+ function getRules() {
64
+ if (!cachedRules) {
65
+ const allRules = loadAllRules(RULES_DIR);
66
+ const detected = detectFrameworks(WORKSPACE);
67
+ cachedRules = activateFrameworkRules(allRules, detected);
68
+ }
69
+ return cachedRules;
70
+ }
71
+
72
+ function getHarnessRules() {
73
+ if (!cachedHarnessRules) {
74
+ cachedHarnessRules = loadHarnessRules(HARNESS_PATH, 'mesh');
75
+ }
76
+ return cachedHarnessRules;
77
+ }
78
+
79
+ /**
80
+ * Inject matching coding rules into a prompt parts array based on task scope.
81
+ */
82
+ function injectRules(parts, scope) {
83
+ // Path-scoped coding standards
84
+ const matched = matchRules(getRules(), scope || []);
85
+ if (matched.length > 0) {
86
+ parts.push(formatRulesForPrompt(matched));
87
+ parts.push('');
88
+ }
89
+
90
+ // Harness behavioral rules (soft enforcement — LLM-agnostic prompt injection)
91
+ const harnessText = formatHarnessForPrompt(getHarnessRules());
92
+ if (harnessText) {
93
+ parts.push(harnessText);
94
+ parts.push('');
95
+ }
96
+ }
97
+
98
+ // ── Role loading (cached per role ID) ─────────────────
99
+ const ROLE_DIRS = [
100
+ path.join(process.env.HOME, '.openclaw', 'roles'),
101
+ path.join(__dirname, '..', 'config', 'roles'),
102
+ ];
103
+ const roleCache = new Map();
104
+
105
+ function getRole(roleId) {
106
+ if (!roleId) return null;
107
+ if (roleCache.has(roleId)) return roleCache.get(roleId);
108
+ const role = findRole(roleId, ROLE_DIRS);
109
+ if (role) roleCache.set(roleId, role);
110
+ return role;
111
+ }
112
+
113
+ /**
114
+ * Inject role profile into prompt parts array.
115
+ */
116
+ function injectRole(parts, roleId) {
117
+ const role = getRole(roleId);
118
+ if (!role) return;
119
+ const roleText = formatRoleForPrompt(role);
120
+ if (roleText) {
121
+ parts.push(roleText);
122
+ parts.push('');
123
+ }
124
+ }
125
+
54
126
  // ── CLI args ──────────────────────────────────────────
55
127
 
56
128
  const args = process.argv.slice(2);
@@ -126,8 +198,9 @@ function buildInitialPrompt(task) {
126
198
  }
127
199
 
128
200
  if (task.metric) {
201
+ const safeMetric = isAllowedMetric(task.metric) ? task.metric : '[metric command filtered for security]';
129
202
  parts.push(`## Verification`);
130
- parts.push(`Run this command to check your work: \`${task.metric}\``);
203
+ parts.push(`Run this command to check your work: \`${safeMetric}\``);
131
204
  parts.push(`Your changes are only accepted if this command exits with code 0.`);
132
205
  parts.push('');
133
206
  }
@@ -141,12 +214,19 @@ function buildInitialPrompt(task) {
141
214
  parts.push('');
142
215
  }
143
216
 
217
+ // Inject path-scoped coding rules matching task scope
218
+ injectRules(parts, task.scope);
219
+
220
+ // Inject role profile (responsibilities, boundaries, framework)
221
+ injectRole(parts, task.role);
222
+
144
223
  parts.push('## Instructions');
145
224
  parts.push('- Read the relevant files before making changes.');
146
225
  parts.push('- Make minimal, focused changes. Do not add scope beyond what is asked.');
147
226
  parts.push('- If you hit a blocker you cannot resolve, explain what is blocking you clearly.');
148
227
  if (task.metric) {
149
- parts.push(`- After making changes, run \`${task.metric}\` to verify.`);
228
+ const safeMetric = isAllowedMetric(task.metric) ? task.metric : '[metric command filtered for security]';
229
+ parts.push(`- After making changes, run \`${safeMetric}\` to verify.`);
150
230
  parts.push('- If verification fails, analyze the failure and iterate on your approach.');
151
231
  }
152
232
 
@@ -186,8 +266,9 @@ function buildRetryPrompt(task, previousAttempts, attemptNumber) {
186
266
  }
187
267
 
188
268
  if (task.metric) {
269
+ const safeMetric = isAllowedMetric(task.metric) ? task.metric : '[metric command filtered for security]';
189
270
  parts.push(`## Verification`);
190
- parts.push(`Run: \`${task.metric}\``);
271
+ parts.push(`Run: \`${safeMetric}\``);
191
272
  parts.push(`Must exit code 0.`);
192
273
  parts.push('');
193
274
  }
@@ -200,12 +281,19 @@ function buildRetryPrompt(task, previousAttempts, attemptNumber) {
200
281
  parts.push('');
201
282
  }
202
283
 
284
+ // Inject path-scoped coding rules matching task scope
285
+ injectRules(parts, task.scope);
286
+
287
+ // Inject role profile (responsibilities, boundaries, framework)
288
+ injectRole(parts, task.role);
289
+
203
290
  parts.push('## Instructions');
204
291
  parts.push('- Do NOT repeat a failed approach. Try something different.');
205
292
  parts.push('- Read the relevant files before making changes.');
206
293
  parts.push('- Make minimal, focused changes.');
207
294
  if (task.metric) {
208
- parts.push(`- Run \`${task.metric}\` to verify before finishing.`);
295
+ const safeMetric = isAllowedMetric(task.metric) ? task.metric : '[metric command filtered for security]';
296
+ parts.push(`- Run \`${safeMetric}\` to verify before finishing.`);
209
297
  }
210
298
 
211
299
  return parts.join('\n');
@@ -221,6 +309,9 @@ const WORKTREE_BASE = process.env.MESH_WORKTREE_BASE || path.join(process.env.HO
221
309
  * On failure, returns null (falls back to shared workspace).
222
310
  */
223
311
  function createWorktree(taskId) {
312
+ if (!/^[\w][\w.-]{0,127}$/.test(taskId)) {
313
+ throw new Error(`Invalid taskId: contains unsafe characters`);
314
+ }
224
315
  const worktreePath = path.join(WORKTREE_BASE, taskId);
225
316
  const branch = `mesh/${taskId}`;
226
317
 
@@ -231,19 +322,19 @@ function createWorktree(taskId) {
231
322
  if (fs.existsSync(worktreePath)) {
232
323
  log(`Cleaning stale worktree: ${worktreePath}`);
233
324
  try {
234
- execSync(`git worktree remove --force "${worktreePath}"`, { cwd: WORKSPACE, timeout: 10000 });
325
+ execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: WORKSPACE, timeout: 10000 });
235
326
  } catch {
236
327
  // If git worktree remove fails, manually clean up
237
328
  fs.rmSync(worktreePath, { recursive: true, force: true });
238
329
  }
239
330
  // Also clean up the branch if it exists
240
331
  try {
241
- execSync(`git branch -D "${branch}"`, { cwd: WORKSPACE, timeout: 5000, stdio: 'ignore' });
332
+ execFileSync('git', ['branch', '-D', branch], { cwd: WORKSPACE, timeout: 5000, stdio: 'ignore' });
242
333
  } catch { /* branch may not exist */ }
243
334
  }
244
335
 
245
336
  // Create new worktree branched off HEAD
246
- execSync(`git worktree add -b "${branch}" "${worktreePath}" HEAD`, {
337
+ execFileSync('git', ['worktree', 'add', '-b', branch, worktreePath, 'HEAD'], {
247
338
  cwd: WORKSPACE,
248
339
  timeout: 30000,
249
340
  stdio: 'pipe',
@@ -284,7 +375,14 @@ function commitAndMergeWorktree(worktreePath, taskId, summary) {
284
375
  // Stage and commit all changes
285
376
  execSync('git add -A', { cwd: worktreePath, timeout: 10000, stdio: 'pipe' });
286
377
  const commitMsg = `mesh(${taskId}): ${(summary || 'task completed').slice(0, 72)}`;
287
- execSync(`git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, {
378
+
379
+ // Pre-commit validation: check conventional commit format before committing
380
+ const conventionalPattern = /^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert|mesh)(\(.+\))?: .+/;
381
+ if (!conventionalPattern.test(commitMsg)) {
382
+ log(`WARNING: commit message doesn't follow conventional format: "${commitMsg}"`);
383
+ }
384
+
385
+ execFileSync('git', ['commit', '-m', commitMsg], {
288
386
  cwd: worktreePath, timeout: 10000, stdio: 'pipe',
289
387
  });
290
388
 
@@ -300,7 +398,7 @@ function commitAndMergeWorktree(worktreePath, taskId, summary) {
300
398
  const mergeMsg = `Merge ${branch}: ${taskId}`;
301
399
  for (let attempt = 0; attempt < 2; attempt++) {
302
400
  try {
303
- execSync(`git merge --no-ff "${branch}" -m "${mergeMsg.replace(/"/g, '\\"')}"`, {
401
+ execFileSync('git', ['merge', '--no-ff', branch, '-m', mergeMsg], {
304
402
  cwd: WORKSPACE, timeout: 30000, stdio: 'pipe',
305
403
  });
306
404
  log(`Merged ${branch} into main${attempt > 0 ? ' (retry succeeded)' : ''}`);
@@ -338,13 +436,13 @@ function cleanupWorktree(worktreePath, keep = false) {
338
436
  const branch = `mesh/${taskId}`;
339
437
 
340
438
  try {
341
- execSync(`git worktree remove --force "${worktreePath}"`, {
439
+ execFileSync('git', ['worktree', 'remove', '--force', worktreePath], {
342
440
  cwd: WORKSPACE,
343
441
  timeout: 10000,
344
442
  stdio: 'pipe',
345
443
  });
346
444
  if (!keep) {
347
- execSync(`git branch -D "${branch}"`, {
445
+ execFileSync('git', ['branch', '-D', branch], {
348
446
  cwd: WORKSPACE,
349
447
  timeout: 5000,
350
448
  stdio: 'ignore',
@@ -416,9 +514,10 @@ function runLLM(prompt, task, worktreePath) {
416
514
 
417
515
  let stdout = '';
418
516
  let stderr = '';
517
+ const MAX_OUTPUT = 1024 * 1024; // 1MB cap
419
518
 
420
- child.stdout.on('data', (d) => { stdout += d.toString(); });
421
- child.stderr.on('data', (d) => { stderr += d.toString(); });
519
+ child.stdout.on('data', (d) => { if (stdout.length < MAX_OUTPUT) stdout += d.toString().slice(0, MAX_OUTPUT - stdout.length); });
520
+ child.stderr.on('data', (d) => { if (stderr.length < MAX_OUTPUT) stderr += d.toString().slice(0, MAX_OUTPUT - stderr.length); });
422
521
 
423
522
  child.on('close', (code) => {
424
523
  clearInterval(heartbeatTimer);
@@ -434,10 +533,23 @@ function runLLM(prompt, task, worktreePath) {
434
533
 
435
534
  // ── Metric Evaluation ─────────────────────────────────
436
535
 
536
+ const ALLOWED_METRIC_PREFIXES = [
537
+ 'npm test', 'npm run', 'node ', 'pytest', 'cargo test',
538
+ 'go test', 'make test', 'jest', 'vitest', 'mocha',
539
+ ];
540
+
541
+ function isAllowedMetric(cmd) {
542
+ return ALLOWED_METRIC_PREFIXES.some(prefix => cmd.startsWith(prefix));
543
+ }
544
+
437
545
  /**
438
546
  * Run the task's metric command. Returns { passed, output }.
439
547
  */
440
548
  function evaluateMetric(metric, cwd) {
549
+ if (!isAllowedMetric(metric)) {
550
+ log(`WARNING: Metric command blocked by security filter: ${metric}`);
551
+ return Promise.resolve({ passed: false, output: 'Metric command blocked by security filter' });
552
+ }
441
553
  return new Promise((resolve) => {
442
554
  const child = spawn('bash', ['-c', metric], {
443
555
  cwd: cwd || WORKSPACE,
@@ -510,6 +622,11 @@ function buildCollabPrompt(task, roundNumber, sharedIntel, myScope, myRole) {
510
622
  }
511
623
  }
512
624
 
625
+ // Inject path-scoped coding rules matching task scope
626
+ const collabScope = Array.isArray(myScope) ? myScope.map(s => s.replace('[REVIEW-ONLY] ', '')) : task.scope;
627
+ injectRules(parts, collabScope);
628
+ injectRole(parts, task.role);
629
+
513
630
  if (task.success_criteria && task.success_criteria.length > 0) {
514
631
  parts.push('## Success Criteria');
515
632
  for (const c of task.success_criteria) {
@@ -625,6 +742,207 @@ function parseReflection(output) {
625
742
  };
626
743
  }
627
744
 
745
+ // ── Circling Strategy: Prompt Builder + Parser ───────
746
+
747
+ /**
748
+ * Build a prompt for a circling strategy step.
749
+ * Role-specific: Worker gets different instructions than Reviewers at each step.
750
+ */
751
+ function buildCirclingPrompt(task, circlingData) {
752
+ const { circling_phase, circling_step, circling_subround, directed_input, my_role } = circlingData;
753
+ const isWorker = my_role === 'worker';
754
+ const parts = [];
755
+
756
+ parts.push(`# Task: ${task.title}`);
757
+ parts.push('');
758
+
759
+ switch (circling_phase) {
760
+ case 'init':
761
+ if (isWorker) {
762
+ parts.push('## Your Role: WORKER (Central Authority)');
763
+ parts.push('');
764
+ parts.push('You are the Worker in a Circling Strategy collaboration. You OWN the deliverable.');
765
+ parts.push('Produce your initial work artifact (v0) — your first draft/implementation based on the task plan below.');
766
+ parts.push('');
767
+ } else {
768
+ parts.push('## Your Role: REVIEWER');
769
+ parts.push('');
770
+ parts.push('You are a Reviewer in a Circling Strategy collaboration.');
771
+ parts.push('Produce your **reviewStrategy** — your methodology and focus areas for reviewing this type of work.');
772
+ parts.push('Define: what you will look for, what frameworks you will apply, what your evaluation criteria are.');
773
+ parts.push('');
774
+ }
775
+ break;
776
+
777
+ case 'circling':
778
+ if (circling_step === 1) {
779
+ // Step 1 — Review Pass
780
+ parts.push(`## Sub-Round ${circling_subround}, Step 1: Review Pass`);
781
+ parts.push('');
782
+ if (isWorker) {
783
+ parts.push('## Your Role: WORKER — Analyze Review Strategies');
784
+ parts.push('');
785
+ parts.push('Below are the review STRATEGIES from both reviewers (NOT their review findings).');
786
+ parts.push('Analyze each strategy: what they focus on well, what they miss, how they could improve.');
787
+ parts.push('Your feedback will help reviewers sharpen their approaches.');
788
+ parts.push('Do NOT touch the work artifact in this step.');
789
+ parts.push('');
790
+ } else {
791
+ parts.push('## Your Role: REVIEWER — Review the Work Artifact');
792
+ parts.push('');
793
+ parts.push('Below is the work artifact. Review it using your review strategy.');
794
+ parts.push('Produce concrete, actionable findings. For each finding, specify:');
795
+ parts.push('- What you found (the issue or observation)');
796
+ parts.push('- Where in the artifact (location/line/section)');
797
+ parts.push('- Why it matters (impact)');
798
+ parts.push('- What you recommend (suggested fix or improvement)');
799
+ parts.push('');
800
+ }
801
+ } else if (circling_step === 2) {
802
+ // Step 2 — Integration + Refinement
803
+ parts.push(`## Sub-Round ${circling_subround}, Step 2: Integration & Refinement`);
804
+ parts.push('');
805
+ if (isWorker) {
806
+ parts.push('## Your Role: WORKER — Judge Reviews & Update Artifact');
807
+ parts.push('');
808
+ parts.push('Below are the review artifacts from both reviewers.');
809
+ parts.push('For EACH review finding, judge it:');
810
+ parts.push('- **ACCEPTED**: implement the suggestion and note where');
811
+ parts.push('- **REJECTED**: explain why with reasoning');
812
+ parts.push('- **MODIFIED**: implement differently and explain why');
813
+ parts.push('');
814
+ parts.push('Then produce your UPDATED work artifact incorporating accepted changes.');
815
+ parts.push('Also produce a reconciliationDoc summarizing all judgments.');
816
+ parts.push('');
817
+ parts.push('You must output TWO artifacts using the multi-artifact format below.');
818
+ parts.push('');
819
+ } else {
820
+ parts.push('## Your Role: REVIEWER — Refine Your Strategy');
821
+ parts.push('');
822
+ parts.push("Below is the Worker's analysis of your review strategy (and the other reviewer's).");
823
+ parts.push("Evaluate the Worker's feedback honestly:");
824
+ parts.push("- If the feedback is valid, adjust your strategy accordingly");
825
+ parts.push("- If you disagree, explain why and keep your approach");
826
+ parts.push('Produce your REFINED reviewStrategy.');
827
+ parts.push('');
828
+ }
829
+ }
830
+ break;
831
+
832
+ case 'finalization':
833
+ parts.push('## FINALIZATION');
834
+ parts.push('');
835
+ if (isWorker) {
836
+ parts.push('## Your Role: WORKER — Final Delivery');
837
+ parts.push('');
838
+ parts.push('Produce your FINAL formatted work artifact.');
839
+ parts.push('Also produce a completionDiff — a checklist comparing original plan items vs what was delivered:');
840
+ parts.push(' [x] Item implemented');
841
+ parts.push(' [ ] Item NOT implemented — reason / deferred to follow-up');
842
+ parts.push('');
843
+ parts.push('You must output TWO artifacts using the multi-artifact format below.');
844
+ parts.push('');
845
+ } else {
846
+ parts.push('## Your Role: REVIEWER — Final Sign-Off');
847
+ parts.push('');
848
+ parts.push('Review the final work artifact against the original task plan.');
849
+ parts.push('Either:');
850
+ parts.push('- **APPROVE**: vote "converged" — the work meets quality standards');
851
+ parts.push('- **FLAG CONCERN**: vote "blocked" — describe remaining critical concerns');
852
+ parts.push('');
853
+ }
854
+ break;
855
+ }
856
+
857
+ // Add directed input
858
+ if (directed_input) {
859
+ parts.push('---');
860
+ parts.push('');
861
+ parts.push(directed_input);
862
+ parts.push('');
863
+ }
864
+
865
+ // Add artifact output instructions
866
+ const isMultiArtifact = isWorker && (circling_step === 2 || circling_phase === 'finalization');
867
+ parts.push('---');
868
+ parts.push('');
869
+
870
+ if (isMultiArtifact) {
871
+ // Multi-artifact output format (Worker Step 2 and Finalization)
872
+ const art1Type = circling_phase === 'finalization' ? 'workArtifact' : 'workArtifact';
873
+ const art2Type = circling_phase === 'finalization' ? 'completionDiff' : 'reconciliationDoc';
874
+
875
+ parts.push('## Output Format (TWO artifacts required)');
876
+ parts.push('');
877
+ parts.push('Produce your first artifact, then wrap it:');
878
+ parts.push('');
879
+ parts.push('===CIRCLING_ARTIFACT===');
880
+ parts.push(`type: ${art1Type}`);
881
+ parts.push('===END_ARTIFACT===');
882
+ parts.push('');
883
+ parts.push('Then produce your second artifact:');
884
+ parts.push('');
885
+ parts.push('===CIRCLING_ARTIFACT===');
886
+ parts.push(`type: ${art2Type}`);
887
+ parts.push('===END_ARTIFACT===');
888
+ parts.push('');
889
+ parts.push('Then end with:');
890
+ } else {
891
+ parts.push('## Output Format');
892
+ parts.push('');
893
+ parts.push('Produce your work above, then end with:');
894
+ }
895
+
896
+ // Determine the correct type for single-artifact output
897
+ let artifactType = 'workArtifact'; // default
898
+ if (!isWorker) {
899
+ if (circling_phase === 'init' || circling_step === 2) artifactType = 'reviewStrategy';
900
+ else if (circling_step === 1) artifactType = 'reviewArtifact';
901
+ else if (circling_phase === 'finalization') artifactType = 'reviewSignOff';
902
+ } else {
903
+ if (circling_step === 1) artifactType = 'workerReviewsAnalysis';
904
+ }
905
+
906
+ parts.push('');
907
+ parts.push('===CIRCLING_REFLECTION===');
908
+ parts.push(`type: ${artifactType}`);
909
+ parts.push('summary: [1-2 sentences about what you did]');
910
+ parts.push('confidence: [0.0 to 1.0]');
911
+ if (circling_phase === 'finalization') {
912
+ parts.push('vote: [converged|blocked]');
913
+ } else {
914
+ parts.push('vote: [continue|converged|blocked]');
915
+ }
916
+ parts.push('===END_REFLECTION===');
917
+ parts.push('');
918
+ parts.push('Rules:');
919
+ parts.push('- Begin your output with the artifact content DIRECTLY. Do NOT include any preamble, explanation, or commentary before the artifact. Any text before the artifact delimiters (or before the reflection block for single-artifact output) is treated as part of the artifact.');
920
+ parts.push('- confidence: a number between 0.0 and 1.0');
921
+ if (circling_phase === 'finalization') {
922
+ parts.push('- vote: "converged" (work meets quality standards) or "blocked" (critical issue remains)');
923
+ } else {
924
+ parts.push('- vote: "continue" (more work needed), "converged" (satisfied), or "blocked" (critical issue)');
925
+ }
926
+ parts.push('- The reflection block MUST be the last thing in your response');
927
+
928
+ return parts.join('\n');
929
+ }
930
+
931
+ // Parser extracted to lib/circling-parser.js — single source of truth for both
932
+ // production code and tests. Zero external dependencies.
933
+ const { parseCirclingReflection: _parseCircling } = require('../lib/circling-parser');
934
+
935
+ /**
936
+ * Parse a circling strategy output. Delegates to lib/circling-parser.js
937
+ * with agent-specific options (logger, legacy fallback).
938
+ */
939
+ function parseCirclingReflection(output) {
940
+ return _parseCircling(output, {
941
+ log: (msg) => log(msg),
942
+ legacyParser: parseReflection,
943
+ });
944
+ }
945
+
628
946
  // ── Collaborative Task Execution ──────────────────────
629
947
 
630
948
  /**
@@ -736,32 +1054,60 @@ async function executeCollabTask(task) {
736
1054
  if (roundsDone) break;
737
1055
 
738
1056
  const roundData = JSON.parse(sc.decode(roundMsg.data));
739
- const { round_number, shared_intel, my_scope, my_role, mode, current_turn } = roundData;
1057
+ const { round_number, shared_intel, directed_input, my_scope, my_role, mode, current_turn,
1058
+ circling_phase, circling_step, circling_subround } = roundData;
740
1059
 
741
1060
  // Sequential mode safety guard: skip if it's not our turn.
742
- // The daemon (notifySequentialTurn) only sends to the current-turn node,
743
- // so this should not normally trigger. Kept as a defensive check.
744
1061
  if (mode === 'sequential' && current_turn && current_turn !== NODE_ID) {
745
1062
  log(`COLLAB R${round_number}: Not our turn (current: ${current_turn}). Waiting.`);
746
1063
  continue;
747
1064
  }
748
1065
 
749
- log(`COLLAB R${round_number}: Starting work (role: ${my_role}, scope: ${JSON.stringify(my_scope)})`);
1066
+ const isCircling = mode === 'circling_strategy';
1067
+ const stepLabel = isCircling
1068
+ ? `${circling_phase === 'init' ? 'Init' : circling_phase === 'finalization' ? 'Final' : `SR${circling_subround}/S${circling_step}`}`
1069
+ : `R${round_number}`;
1070
+ log(`COLLAB ${stepLabel}: Starting work (role: ${my_role}, scope: ${JSON.stringify(my_scope)})`);
750
1071
 
751
- // Build round-specific prompt
752
- const prompt = buildCollabPrompt(task, round_number, shared_intel, my_scope, my_role);
1072
+ // Build prompt — circling uses directed inputs, other modes use shared intel
1073
+ const prompt = isCircling
1074
+ ? buildCirclingPrompt(task, roundData)
1075
+ : buildCollabPrompt(task, round_number, shared_intel, my_scope, my_role);
753
1076
 
754
1077
  if (DRY_RUN) {
755
1078
  log(`[DRY RUN] Collab prompt:\n${prompt}`);
756
1079
  break;
757
1080
  }
758
1081
 
759
- // Execute Claude
1082
+ // Execute provider (LLM or shell)
760
1083
  const llmResult = await runLLM(prompt, task, worktreePath);
761
1084
  const output = llmResult.stdout || '';
762
1085
 
763
- // Parse reflection from output
764
- const reflection = parseReflection(output);
1086
+ // Parse reflection circling uses delimiter-based parser, others use JSON-block parser
1087
+ let reflection;
1088
+ let circlingArtifacts = [];
1089
+
1090
+ if (llmResult.provider === 'shell') {
1091
+ reflection = {
1092
+ summary: output.trim().slice(-500) || '(no output)',
1093
+ learnings: '',
1094
+ confidence: llmResult.exitCode === 0 ? 1.0 : 0.0,
1095
+ vote: llmResult.exitCode === 0 ? 'converged' : 'continue',
1096
+ parse_failed: false,
1097
+ };
1098
+ } else if (isCircling) {
1099
+ const circResult = parseCirclingReflection(output);
1100
+ reflection = {
1101
+ summary: circResult.summary,
1102
+ learnings: '',
1103
+ confidence: circResult.confidence,
1104
+ vote: circResult.vote,
1105
+ parse_failed: circResult.parse_failed,
1106
+ };
1107
+ circlingArtifacts = circResult.circling_artifacts;
1108
+ } else {
1109
+ reflection = parseReflection(output);
1110
+ }
765
1111
 
766
1112
  // List modified files
767
1113
  let artifacts = [];
@@ -781,16 +1127,20 @@ async function executeCollabTask(task) {
781
1127
  node_id: NODE_ID,
782
1128
  round: round_number,
783
1129
  summary: reflection.summary,
784
- learnings: reflection.learnings,
1130
+ learnings: reflection.learnings || '',
785
1131
  artifacts,
786
1132
  confidence: reflection.confidence,
787
1133
  vote: reflection.vote,
788
1134
  parse_failed: reflection.parse_failed,
1135
+ // Circling extensions
1136
+ circling_step: isCircling ? circling_step : null,
1137
+ circling_artifacts: circlingArtifacts,
789
1138
  });
790
1139
  const parseTag = reflection.parse_failed ? ' [PARSE FAILED]' : '';
791
- log(`COLLAB R${round_number}: Reflection submitted (vote: ${reflection.vote}, conf: ${reflection.confidence}${parseTag})`);
1140
+ const artCount = circlingArtifacts.length > 0 ? `, ${circlingArtifacts.length} artifact(s)` : '';
1141
+ log(`COLLAB ${stepLabel}: Reflection submitted (vote: ${reflection.vote}, conf: ${reflection.confidence}${parseTag}${artCount})`);
792
1142
  } catch (err) {
793
- log(`COLLAB R${round_number}: Reflection submit failed: ${err.message}`);
1143
+ log(`COLLAB ${stepLabel}: Reflection submit failed: ${err.message}`);
794
1144
  }
795
1145
 
796
1146
  // Check if session is done (converged/completed/aborted)
@@ -897,6 +1247,48 @@ async function executeTask(task) {
897
1247
  continue;
898
1248
  }
899
1249
 
1250
+ // ── Mesh Harness: Mechanical Enforcement (LLM-agnostic) ──
1251
+ // Runs AFTER LLM exits successfully, BEFORE commit. This is the hard
1252
+ // enforcement layer — it doesn't depend on the LLM obeying prompt rules.
1253
+ const harnessResult = runMeshHarness({
1254
+ rules: getHarnessRules(),
1255
+ worktreePath,
1256
+ taskScope: task.scope,
1257
+ llmOutput: llmResult.stdout,
1258
+ hasMetric: !!task.metric,
1259
+ log,
1260
+ role: getRole(task.role),
1261
+ });
1262
+
1263
+ if (!harnessResult.pass) {
1264
+ log(`HARNESS BLOCKED: ${harnessResult.violations.length} violation(s)`);
1265
+ for (const v of harnessResult.violations) {
1266
+ log(` - [${v.rule}] ${v.message}`);
1267
+ }
1268
+ // Scope violations were already reverted by enforceScopeCheck.
1269
+ // Secret violations block the commit entirely.
1270
+ const hasSecrets = harnessResult.violations.some(v => v.rule === 'no-hardcoded-secrets');
1271
+ if (hasSecrets) {
1272
+ const attemptRecord = {
1273
+ approach: `Attempt ${attempt}: harness blocked — secrets detected`,
1274
+ result: harnessResult.violations.map(v => v.message).join('; '),
1275
+ keep: false,
1276
+ };
1277
+ attempts.push(attemptRecord);
1278
+ await natsRequest('mesh.tasks.attempt', { task_id: task.task_id, ...attemptRecord });
1279
+ log(`Attempt ${attempt}: harness blocked commit (secrets). Retrying.`);
1280
+ continue;
1281
+ }
1282
+ // Non-secret violations (scope reverts, output blocks): proceed with warnings
1283
+ }
1284
+
1285
+ if (harnessResult.warnings.length > 0) {
1286
+ log(`HARNESS WARNINGS: ${harnessResult.warnings.length}`);
1287
+ for (const w of harnessResult.warnings) {
1288
+ log(` - [${w.rule}] ${w.message}`);
1289
+ }
1290
+ }
1291
+
900
1292
  // If no metric, trust LLM output and complete
901
1293
  if (!task.metric) {
902
1294
  const attemptRecord = {
@@ -911,11 +1303,20 @@ async function executeTask(task) {
911
1303
  const mergeResult = commitAndMergeWorktree(worktreePath, task.task_id, summary);
912
1304
  const keepBranch = mergeResult && !mergeResult.merged; // keep on merge conflict
913
1305
 
1306
+ // Post-commit validation (conventional commits, etc.)
1307
+ if (mergeResult?.committed) {
1308
+ runPostCommitValidation(getHarnessRules(), worktreePath, log);
1309
+ }
1310
+
914
1311
  await natsRequest('mesh.tasks.complete', {
915
1312
  task_id: task.task_id,
916
1313
  result: {
917
1314
  success: true, summary, artifacts: [],
918
1315
  cost: sessionInfo?.cost || null,
1316
+ harness: {
1317
+ violations: harnessResult.violations,
1318
+ warnings: harnessResult.warnings,
1319
+ },
919
1320
  sha: mergeResult?.sha || null,
920
1321
  merged: mergeResult?.merged ?? null,
921
1322
  },
@@ -943,6 +1344,11 @@ async function executeTask(task) {
943
1344
  const mergeResult = commitAndMergeWorktree(worktreePath, task.task_id, summary);
944
1345
  const keepBranch = mergeResult && !mergeResult.merged;
945
1346
 
1347
+ // Post-commit validation (conventional commits, etc.)
1348
+ if (mergeResult?.committed) {
1349
+ runPostCommitValidation(getHarnessRules(), worktreePath, log);
1350
+ }
1351
+
946
1352
  await natsRequest('mesh.tasks.complete', {
947
1353
  task_id: task.task_id,
948
1354
  result: {
@@ -952,6 +1358,10 @@ async function executeTask(task) {
952
1358
  cost: sessionInfo?.cost || null,
953
1359
  sha: mergeResult?.sha || null,
954
1360
  merged: mergeResult?.merged ?? null,
1361
+ harness: {
1362
+ violations: harnessResult.violations,
1363
+ warnings: harnessResult.warnings,
1364
+ },
955
1365
  },
956
1366
  });
957
1367
  cleanupWorktree(worktreePath, keepBranch);
@@ -1005,8 +1415,9 @@ async function main() {
1005
1415
  log(` Poll interval: ${POLL_INTERVAL / 1000}s`);
1006
1416
  log(` Mode: ${ONCE ? 'single task' : 'continuous'} ${DRY_RUN ? '(dry run)' : ''}`);
1007
1417
 
1418
+ const natsOpts = natsConnectOpts();
1008
1419
  nc = await connect({
1009
- servers: NATS_URL,
1420
+ ...natsOpts,
1010
1421
  timeout: 5000,
1011
1422
  reconnect: true,
1012
1423
  maxReconnectAttempts: 10,