openclaw-node-harness 2.0.3 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +646 -3
  2. package/bin/hyperagent.mjs +419 -0
  3. package/bin/mesh-agent.js +603 -81
  4. package/bin/mesh-bridge.js +340 -11
  5. package/bin/mesh-deploy-listener.js +119 -97
  6. package/bin/mesh-deploy.js +8 -0
  7. package/bin/mesh-task-daemon.js +1005 -40
  8. package/bin/mesh.js +423 -6
  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 +300 -8
  29. package/lib/circling-parser.js +119 -0
  30. package/lib/hyperagent-store.mjs +652 -0
  31. package/lib/kanban-io.js +59 -10
  32. package/lib/mcp-knowledge/bench.mjs +118 -0
  33. package/lib/mcp-knowledge/core.mjs +528 -0
  34. package/lib/mcp-knowledge/package.json +25 -0
  35. package/lib/mcp-knowledge/server.mjs +245 -0
  36. package/lib/mcp-knowledge/test.mjs +802 -0
  37. package/lib/memory-budget.mjs +261 -0
  38. package/lib/mesh-collab.js +354 -4
  39. package/lib/mesh-harness.js +427 -0
  40. package/lib/mesh-plans.js +13 -5
  41. package/lib/mesh-registry.js +11 -2
  42. package/lib/mesh-tasks.js +67 -0
  43. package/lib/plan-templates.js +226 -0
  44. package/lib/pre-compression-flush.mjs +320 -0
  45. package/lib/role-loader.js +292 -0
  46. package/lib/rule-loader.js +358 -0
  47. package/lib/session-store.mjs +458 -0
  48. package/lib/transcript-parser.mjs +292 -0
  49. package/mission-control/drizzle/soul_schema_update.sql +29 -0
  50. package/mission-control/drizzle.config.ts +1 -4
  51. package/mission-control/package-lock.json +1571 -83
  52. package/mission-control/package.json +6 -2
  53. package/mission-control/scripts/gen-chronology.js +3 -3
  54. package/mission-control/scripts/import-pipeline-v2.js +0 -16
  55. package/mission-control/scripts/import-pipeline.js +0 -15
  56. package/mission-control/src/app/api/cowork/clusters/[id]/members/route.ts +117 -0
  57. package/mission-control/src/app/api/cowork/clusters/[id]/route.ts +84 -0
  58. package/mission-control/src/app/api/cowork/clusters/route.ts +141 -0
  59. package/mission-control/src/app/api/cowork/dispatch/route.ts +128 -0
  60. package/mission-control/src/app/api/cowork/events/route.ts +65 -0
  61. package/mission-control/src/app/api/cowork/intervene/route.ts +259 -0
  62. package/mission-control/src/app/api/cowork/sessions/[id]/route.ts +37 -0
  63. package/mission-control/src/app/api/cowork/sessions/route.ts +64 -0
  64. package/mission-control/src/app/api/diagnostics/route.ts +97 -0
  65. package/mission-control/src/app/api/diagnostics/test-runner/route.ts +990 -0
  66. package/mission-control/src/app/api/mesh/events/route.ts +95 -19
  67. package/mission-control/src/app/api/mesh/identity/route.ts +11 -0
  68. package/mission-control/src/app/api/mesh/tasks/[id]/route.ts +92 -0
  69. package/mission-control/src/app/api/mesh/tasks/route.ts +91 -0
  70. package/mission-control/src/app/api/tasks/[id]/handoff/route.ts +1 -1
  71. package/mission-control/src/app/api/tasks/[id]/route.ts +90 -4
  72. package/mission-control/src/app/api/tasks/route.ts +21 -30
  73. package/mission-control/src/app/cowork/page.tsx +261 -0
  74. package/mission-control/src/app/diagnostics/page.tsx +385 -0
  75. package/mission-control/src/app/graph/page.tsx +26 -0
  76. package/mission-control/src/app/memory/page.tsx +1 -1
  77. package/mission-control/src/app/obsidian/page.tsx +36 -6
  78. package/mission-control/src/app/roadmap/page.tsx +24 -0
  79. package/mission-control/src/app/souls/page.tsx +2 -2
  80. package/mission-control/src/components/board/execution-config.tsx +431 -0
  81. package/mission-control/src/components/board/kanban-board.tsx +75 -9
  82. package/mission-control/src/components/board/kanban-column.tsx +135 -19
  83. package/mission-control/src/components/board/task-card.tsx +55 -2
  84. package/mission-control/src/components/board/unified-task-dialog.tsx +82 -4
  85. package/mission-control/src/components/cowork/cluster-card.tsx +176 -0
  86. package/mission-control/src/components/cowork/create-cluster-dialog.tsx +251 -0
  87. package/mission-control/src/components/cowork/dispatch-form.tsx +423 -0
  88. package/mission-control/src/components/cowork/role-picker.tsx +102 -0
  89. package/mission-control/src/components/cowork/session-card.tsx +284 -0
  90. package/mission-control/src/components/layout/sidebar.tsx +39 -2
  91. package/mission-control/src/lib/__tests__/daily-log.test.ts +82 -0
  92. package/mission-control/src/lib/__tests__/memory-md.test.ts +87 -0
  93. package/mission-control/src/lib/__tests__/mesh-kv-sync.test.ts +465 -0
  94. package/mission-control/src/lib/__tests__/mocks/mock-kv.ts +131 -0
  95. package/mission-control/src/lib/__tests__/status-kanban.test.ts +46 -0
  96. package/mission-control/src/lib/__tests__/task-markdown.test.ts +188 -0
  97. package/mission-control/src/lib/__tests__/wikilinks.test.ts +175 -0
  98. package/mission-control/src/lib/config.ts +58 -0
  99. package/mission-control/src/lib/db/index.ts +69 -0
  100. package/mission-control/src/lib/db/schema.ts +61 -3
  101. package/mission-control/src/lib/hooks.ts +309 -0
  102. package/mission-control/src/lib/memory/entities.ts +3 -2
  103. package/mission-control/src/lib/nats.ts +66 -1
  104. package/mission-control/src/lib/parsers/task-markdown.ts +52 -2
  105. package/mission-control/src/lib/parsers/transcript.ts +4 -4
  106. package/mission-control/src/lib/scheduler.ts +12 -11
  107. package/mission-control/src/lib/sync/mesh-kv.ts +279 -0
  108. package/mission-control/src/lib/sync/tasks.ts +23 -1
  109. package/mission-control/src/lib/task-id.ts +32 -0
  110. package/mission-control/src/lib/tts/index.ts +33 -9
  111. package/mission-control/tsconfig.json +2 -1
  112. package/mission-control/vitest.config.ts +14 -0
  113. package/package.json +15 -2
  114. package/services/service-manifest.json +1 -1
  115. package/skills/cc-godmode/references/agents.md +8 -8
  116. package/workspace-bin/memory-daemon.mjs +199 -5
  117. package/workspace-bin/session-search.mjs +204 -0
  118. package/workspace-bin/web-fetch.mjs +65 -0
package/bin/mesh-agent.js CHANGED
@@ -41,6 +41,9 @@ 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
49
  const { NATS_URL } = require('../lib/nats-resolve');
@@ -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);
@@ -141,6 +213,12 @@ function buildInitialPrompt(task) {
141
213
  parts.push('');
142
214
  }
143
215
 
216
+ // Inject path-scoped coding rules matching task scope
217
+ injectRules(parts, task.scope);
218
+
219
+ // Inject role profile (responsibilities, boundaries, framework)
220
+ injectRole(parts, task.role);
221
+
144
222
  parts.push('## Instructions');
145
223
  parts.push('- Read the relevant files before making changes.');
146
224
  parts.push('- Make minimal, focused changes. Do not add scope beyond what is asked.');
@@ -200,6 +278,12 @@ function buildRetryPrompt(task, previousAttempts, attemptNumber) {
200
278
  parts.push('');
201
279
  }
202
280
 
281
+ // Inject path-scoped coding rules matching task scope
282
+ injectRules(parts, task.scope);
283
+
284
+ // Inject role profile (responsibilities, boundaries, framework)
285
+ injectRole(parts, task.role);
286
+
203
287
  parts.push('## Instructions');
204
288
  parts.push('- Do NOT repeat a failed approach. Try something different.');
205
289
  parts.push('- Read the relevant files before making changes.');
@@ -284,6 +368,13 @@ function commitAndMergeWorktree(worktreePath, taskId, summary) {
284
368
  // Stage and commit all changes
285
369
  execSync('git add -A', { cwd: worktreePath, timeout: 10000, stdio: 'pipe' });
286
370
  const commitMsg = `mesh(${taskId}): ${(summary || 'task completed').slice(0, 72)}`;
371
+
372
+ // Pre-commit validation: check conventional commit format before committing
373
+ const conventionalPattern = /^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert|mesh)(\(.+\))?: .+/;
374
+ if (!conventionalPattern.test(commitMsg)) {
375
+ log(`WARNING: commit message doesn't follow conventional format: "${commitMsg}"`);
376
+ }
377
+
287
378
  execSync(`git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, {
288
379
  cwd: worktreePath, timeout: 10000, stdio: 'pipe',
289
380
  });
@@ -294,18 +385,31 @@ function commitAndMergeWorktree(worktreePath, taskId, summary) {
294
385
 
295
386
  log(`Committed ${sha} on ${branch}: ${commitMsg}`);
296
387
 
297
- // Merge into main (from workspace)
298
- try {
299
- execSync(`git merge --no-ff "${branch}" -m "Merge ${branch}: ${taskId}"`, {
300
- cwd: WORKSPACE, timeout: 30000, stdio: 'pipe',
301
- });
302
- log(`Merged ${branch} into main`);
303
- return { committed: true, merged: true, sha };
304
- } catch (mergeErr) {
305
- // Merge conflict — abort and keep branch for human resolution
306
- execSync('git merge --abort', { cwd: WORKSPACE, timeout: 5000, stdio: 'ignore' });
307
- log(`MERGE CONFLICT on ${branch} branch kept for manual resolution`);
308
- return { committed: true, merged: false, sha };
388
+ // Merge into main (from workspace).
389
+ // Parallel collab: multiple nodes may merge concurrently. If the first attempt
390
+ // fails (e.g., another node merged first), retry once after pulling.
391
+ const mergeMsg = `Merge ${branch}: ${taskId}`;
392
+ for (let attempt = 0; attempt < 2; attempt++) {
393
+ try {
394
+ execSync(`git merge --no-ff "${branch}" -m "${mergeMsg.replace(/"/g, '\\"')}"`, {
395
+ cwd: WORKSPACE, timeout: 30000, stdio: 'pipe',
396
+ });
397
+ log(`Merged ${branch} into main${attempt > 0 ? ' (retry succeeded)' : ''}`);
398
+ return { committed: true, merged: true, sha };
399
+ } catch (mergeErr) {
400
+ execSync('git merge --abort', { cwd: WORKSPACE, timeout: 5000, stdio: 'ignore' });
401
+ if (attempt === 0) {
402
+ // First failure: pull and retry (handles race with parallel merge)
403
+ try {
404
+ log(`Merge attempt 1 failed for ${branch} — fast-forward pulling and retrying`);
405
+ execSync('git pull --ff-only', { cwd: WORKSPACE, timeout: 15000, stdio: 'pipe' });
406
+ } catch { /* best effort pull */ }
407
+ } else {
408
+ // Second failure: real conflict — keep branch for human resolution
409
+ log(`MERGE CONFLICT on ${branch} — branch kept for manual resolution`);
410
+ return { committed: true, merged: false, sha, conflict: true };
411
+ }
412
+ }
309
413
  }
310
414
  } catch (err) {
311
415
  log(`Commit/merge warning: ${err.message}`);
@@ -377,14 +481,23 @@ function runLLM(prompt, task, worktreePath) {
377
481
  timeout: (task.budget_minutes || 30) * 60 * 1000, // kill if exceeds budget
378
482
  });
379
483
 
380
- // Heartbeat: signal daemon with activity state
484
+ // Heartbeat: signal daemon with activity state.
485
+ // getActivityState reads Claude JSONL files — only useful for Claude provider.
486
+ // For other providers, send a basic heartbeat (process alive = active).
487
+ const isClaude = provider.name === 'claude';
381
488
  const heartbeatTimer = setInterval(async () => {
382
489
  try {
383
- const activity = await getActivityState(cleanCwd);
384
490
  const payload = { task_id: task.task_id };
385
- if (activity) {
386
- payload.activity_state = activity.state;
387
- payload.activity_timestamp = activity.timestamp?.toISOString();
491
+ if (isClaude) {
492
+ const activity = await getActivityState(cleanCwd);
493
+ if (activity) {
494
+ payload.activity_state = activity.state;
495
+ payload.activity_timestamp = activity.timestamp?.toISOString();
496
+ }
497
+ } else {
498
+ // Non-Claude: process is running → active
499
+ payload.activity_state = 'active';
500
+ payload.activity_timestamp = new Date().toISOString();
388
501
  }
389
502
  await natsRequest('mesh.tasks.heartbeat', payload);
390
503
  } catch {
@@ -488,6 +601,11 @@ function buildCollabPrompt(task, roundNumber, sharedIntel, myScope, myRole) {
488
601
  }
489
602
  }
490
603
 
604
+ // Inject path-scoped coding rules matching task scope
605
+ const collabScope = Array.isArray(myScope) ? myScope.map(s => s.replace('[REVIEW-ONLY] ', '')) : task.scope;
606
+ injectRules(parts, collabScope);
607
+ injectRole(parts, task.role);
608
+
491
609
  if (task.success_criteria && task.success_criteria.length > 0) {
492
610
  parts.push('## Success Criteria');
493
611
  for (const c of task.success_criteria) {
@@ -603,6 +721,207 @@ function parseReflection(output) {
603
721
  };
604
722
  }
605
723
 
724
+ // ── Circling Strategy: Prompt Builder + Parser ───────
725
+
726
+ /**
727
+ * Build a prompt for a circling strategy step.
728
+ * Role-specific: Worker gets different instructions than Reviewers at each step.
729
+ */
730
+ function buildCirclingPrompt(task, circlingData) {
731
+ const { circling_phase, circling_step, circling_subround, directed_input, my_role } = circlingData;
732
+ const isWorker = my_role === 'worker';
733
+ const parts = [];
734
+
735
+ parts.push(`# Task: ${task.title}`);
736
+ parts.push('');
737
+
738
+ switch (circling_phase) {
739
+ case 'init':
740
+ if (isWorker) {
741
+ parts.push('## Your Role: WORKER (Central Authority)');
742
+ parts.push('');
743
+ parts.push('You are the Worker in a Circling Strategy collaboration. You OWN the deliverable.');
744
+ parts.push('Produce your initial work artifact (v0) — your first draft/implementation based on the task plan below.');
745
+ parts.push('');
746
+ } else {
747
+ parts.push('## Your Role: REVIEWER');
748
+ parts.push('');
749
+ parts.push('You are a Reviewer in a Circling Strategy collaboration.');
750
+ parts.push('Produce your **reviewStrategy** — your methodology and focus areas for reviewing this type of work.');
751
+ parts.push('Define: what you will look for, what frameworks you will apply, what your evaluation criteria are.');
752
+ parts.push('');
753
+ }
754
+ break;
755
+
756
+ case 'circling':
757
+ if (circling_step === 1) {
758
+ // Step 1 — Review Pass
759
+ parts.push(`## Sub-Round ${circling_subround}, Step 1: Review Pass`);
760
+ parts.push('');
761
+ if (isWorker) {
762
+ parts.push('## Your Role: WORKER — Analyze Review Strategies');
763
+ parts.push('');
764
+ parts.push('Below are the review STRATEGIES from both reviewers (NOT their review findings).');
765
+ parts.push('Analyze each strategy: what they focus on well, what they miss, how they could improve.');
766
+ parts.push('Your feedback will help reviewers sharpen their approaches.');
767
+ parts.push('Do NOT touch the work artifact in this step.');
768
+ parts.push('');
769
+ } else {
770
+ parts.push('## Your Role: REVIEWER — Review the Work Artifact');
771
+ parts.push('');
772
+ parts.push('Below is the work artifact. Review it using your review strategy.');
773
+ parts.push('Produce concrete, actionable findings. For each finding, specify:');
774
+ parts.push('- What you found (the issue or observation)');
775
+ parts.push('- Where in the artifact (location/line/section)');
776
+ parts.push('- Why it matters (impact)');
777
+ parts.push('- What you recommend (suggested fix or improvement)');
778
+ parts.push('');
779
+ }
780
+ } else if (circling_step === 2) {
781
+ // Step 2 — Integration + Refinement
782
+ parts.push(`## Sub-Round ${circling_subround}, Step 2: Integration & Refinement`);
783
+ parts.push('');
784
+ if (isWorker) {
785
+ parts.push('## Your Role: WORKER — Judge Reviews & Update Artifact');
786
+ parts.push('');
787
+ parts.push('Below are the review artifacts from both reviewers.');
788
+ parts.push('For EACH review finding, judge it:');
789
+ parts.push('- **ACCEPTED**: implement the suggestion and note where');
790
+ parts.push('- **REJECTED**: explain why with reasoning');
791
+ parts.push('- **MODIFIED**: implement differently and explain why');
792
+ parts.push('');
793
+ parts.push('Then produce your UPDATED work artifact incorporating accepted changes.');
794
+ parts.push('Also produce a reconciliationDoc summarizing all judgments.');
795
+ parts.push('');
796
+ parts.push('You must output TWO artifacts using the multi-artifact format below.');
797
+ parts.push('');
798
+ } else {
799
+ parts.push('## Your Role: REVIEWER — Refine Your Strategy');
800
+ parts.push('');
801
+ parts.push("Below is the Worker's analysis of your review strategy (and the other reviewer's).");
802
+ parts.push("Evaluate the Worker's feedback honestly:");
803
+ parts.push("- If the feedback is valid, adjust your strategy accordingly");
804
+ parts.push("- If you disagree, explain why and keep your approach");
805
+ parts.push('Produce your REFINED reviewStrategy.');
806
+ parts.push('');
807
+ }
808
+ }
809
+ break;
810
+
811
+ case 'finalization':
812
+ parts.push('## FINALIZATION');
813
+ parts.push('');
814
+ if (isWorker) {
815
+ parts.push('## Your Role: WORKER — Final Delivery');
816
+ parts.push('');
817
+ parts.push('Produce your FINAL formatted work artifact.');
818
+ parts.push('Also produce a completionDiff — a checklist comparing original plan items vs what was delivered:');
819
+ parts.push(' [x] Item implemented');
820
+ parts.push(' [ ] Item NOT implemented — reason / deferred to follow-up');
821
+ parts.push('');
822
+ parts.push('You must output TWO artifacts using the multi-artifact format below.');
823
+ parts.push('');
824
+ } else {
825
+ parts.push('## Your Role: REVIEWER — Final Sign-Off');
826
+ parts.push('');
827
+ parts.push('Review the final work artifact against the original task plan.');
828
+ parts.push('Either:');
829
+ parts.push('- **APPROVE**: vote "converged" — the work meets quality standards');
830
+ parts.push('- **FLAG CONCERN**: vote "blocked" — describe remaining critical concerns');
831
+ parts.push('');
832
+ }
833
+ break;
834
+ }
835
+
836
+ // Add directed input
837
+ if (directed_input) {
838
+ parts.push('---');
839
+ parts.push('');
840
+ parts.push(directed_input);
841
+ parts.push('');
842
+ }
843
+
844
+ // Add artifact output instructions
845
+ const isMultiArtifact = isWorker && (circling_step === 2 || circling_phase === 'finalization');
846
+ parts.push('---');
847
+ parts.push('');
848
+
849
+ if (isMultiArtifact) {
850
+ // Multi-artifact output format (Worker Step 2 and Finalization)
851
+ const art1Type = circling_phase === 'finalization' ? 'workArtifact' : 'workArtifact';
852
+ const art2Type = circling_phase === 'finalization' ? 'completionDiff' : 'reconciliationDoc';
853
+
854
+ parts.push('## Output Format (TWO artifacts required)');
855
+ parts.push('');
856
+ parts.push('Produce your first artifact, then wrap it:');
857
+ parts.push('');
858
+ parts.push('===CIRCLING_ARTIFACT===');
859
+ parts.push(`type: ${art1Type}`);
860
+ parts.push('===END_ARTIFACT===');
861
+ parts.push('');
862
+ parts.push('Then produce your second artifact:');
863
+ parts.push('');
864
+ parts.push('===CIRCLING_ARTIFACT===');
865
+ parts.push(`type: ${art2Type}`);
866
+ parts.push('===END_ARTIFACT===');
867
+ parts.push('');
868
+ parts.push('Then end with:');
869
+ } else {
870
+ parts.push('## Output Format');
871
+ parts.push('');
872
+ parts.push('Produce your work above, then end with:');
873
+ }
874
+
875
+ // Determine the correct type for single-artifact output
876
+ let artifactType = 'workArtifact'; // default
877
+ if (!isWorker) {
878
+ if (circling_phase === 'init' || circling_step === 2) artifactType = 'reviewStrategy';
879
+ else if (circling_step === 1) artifactType = 'reviewArtifact';
880
+ else if (circling_phase === 'finalization') artifactType = 'reviewSignOff';
881
+ } else {
882
+ if (circling_step === 1) artifactType = 'workerReviewsAnalysis';
883
+ }
884
+
885
+ parts.push('');
886
+ parts.push('===CIRCLING_REFLECTION===');
887
+ parts.push(`type: ${artifactType}`);
888
+ parts.push('summary: [1-2 sentences about what you did]');
889
+ parts.push('confidence: [0.0 to 1.0]');
890
+ if (circling_phase === 'finalization') {
891
+ parts.push('vote: [converged|blocked]');
892
+ } else {
893
+ parts.push('vote: [continue|converged|blocked]');
894
+ }
895
+ parts.push('===END_REFLECTION===');
896
+ parts.push('');
897
+ parts.push('Rules:');
898
+ 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.');
899
+ parts.push('- confidence: a number between 0.0 and 1.0');
900
+ if (circling_phase === 'finalization') {
901
+ parts.push('- vote: "converged" (work meets quality standards) or "blocked" (critical issue remains)');
902
+ } else {
903
+ parts.push('- vote: "continue" (more work needed), "converged" (satisfied), or "blocked" (critical issue)');
904
+ }
905
+ parts.push('- The reflection block MUST be the last thing in your response');
906
+
907
+ return parts.join('\n');
908
+ }
909
+
910
+ // Parser extracted to lib/circling-parser.js — single source of truth for both
911
+ // production code and tests. Zero external dependencies.
912
+ const { parseCirclingReflection: _parseCircling } = require('../lib/circling-parser');
913
+
914
+ /**
915
+ * Parse a circling strategy output. Delegates to lib/circling-parser.js
916
+ * with agent-specific options (logger, legacy fallback).
917
+ */
918
+ function parseCirclingReflection(output) {
919
+ return _parseCircling(output, {
920
+ log: (msg) => log(msg),
921
+ legacyParser: parseReflection,
922
+ });
923
+ }
924
+
606
925
  // ── Collaborative Task Execution ──────────────────────
607
926
 
608
927
  /**
@@ -649,6 +968,13 @@ async function executeCollabTask(task) {
649
968
  return;
650
969
  }
651
970
 
971
+ // Subscribe to round notifications BEFORE joining — prevents race condition
972
+ // where the daemon starts round 1 immediately upon last node joining,
973
+ // but the joining node hasn't subscribed yet and misses the notification.
974
+ const roundSub = nc.subscribe(`mesh.collab.${sessionId}.node.${NODE_ID}.round`);
975
+ let roundsDone = false;
976
+ let lastKnownSessionStatus = null; // tracks why rounds ended (completed/aborted/converged)
977
+
652
978
  // Join the session using the discovered session_id
653
979
  let session;
654
980
  try {
@@ -659,6 +985,7 @@ async function executeCollabTask(task) {
659
985
  session = joinResult;
660
986
  } catch (err) {
661
987
  log(`COLLAB JOIN FAILED: ${err.message} (session: ${sessionId})`);
988
+ roundSub.unsubscribe();
662
989
  await natsRequest('mesh.tasks.fail', {
663
990
  task_id: task.task_id,
664
991
  reason: `Failed to join collab session ${sessionId}: ${err.message}`,
@@ -669,6 +996,7 @@ async function executeCollabTask(task) {
669
996
 
670
997
  if (!session) {
671
998
  log(`COLLAB JOIN RETURNED NULL for session ${sessionId}`);
999
+ roundSub.unsubscribe();
672
1000
  await natsRequest('mesh.tasks.fail', {
673
1001
  task_id: task.task_id,
674
1002
  reason: `Collab session ${sessionId} rejected join (full, closed, or duplicate node).`,
@@ -684,87 +1012,146 @@ async function executeCollabTask(task) {
684
1012
  const worktreePath = createWorktree(`${task.task_id}-${NODE_ID}`);
685
1013
  const taskDir = worktreePath || WORKSPACE;
686
1014
 
687
- // Subscribe to round notifications for this session and this node
688
- const roundSub = nc.subscribe(`mesh.collab.${sessionId}.node.${NODE_ID}.round`);
689
- let roundsDone = false;
1015
+ // Periodic session heartbeat detects abort/completion while waiting for rounds
1016
+ const sessionHeartbeat = setInterval(async () => {
1017
+ try {
1018
+ const status = await natsRequest('mesh.collab.status', { session_id: sessionId }, 5000);
1019
+ if (['aborted', 'completed'].includes(status.status)) {
1020
+ log(`COLLAB HEARTBEAT: Session ${sessionId} is ${status.status}. Unsubscribing.`);
1021
+ lastKnownSessionStatus = status.status;
1022
+ roundsDone = true;
1023
+ roundSub.unsubscribe();
1024
+ }
1025
+ } catch { /* best effort */ }
1026
+ }, 10000);
690
1027
 
691
1028
  // Signal start
692
1029
  await natsRequest('mesh.tasks.start', { task_id: task.task_id }).catch(() => {});
693
1030
 
694
- for await (const roundMsg of roundSub) {
695
- if (roundsDone) break;
1031
+ try {
1032
+ for await (const roundMsg of roundSub) {
1033
+ if (roundsDone) break;
696
1034
 
697
- const roundData = JSON.parse(sc.decode(roundMsg.data));
698
- const { round_number, shared_intel, my_scope, my_role, mode, current_turn } = roundData;
1035
+ const roundData = JSON.parse(sc.decode(roundMsg.data));
1036
+ const { round_number, shared_intel, directed_input, my_scope, my_role, mode, current_turn,
1037
+ circling_phase, circling_step, circling_subround } = roundData;
699
1038
 
700
- // Sequential mode: skip if it's not our turn
701
- if (mode === 'sequential' && current_turn && current_turn !== NODE_ID) {
702
- log(`COLLAB R${round_number}: Not our turn (current: ${current_turn}). Waiting.`);
703
- continue;
704
- }
1039
+ // Sequential mode safety guard: skip if it's not our turn.
1040
+ if (mode === 'sequential' && current_turn && current_turn !== NODE_ID) {
1041
+ log(`COLLAB R${round_number}: Not our turn (current: ${current_turn}). Waiting.`);
1042
+ continue;
1043
+ }
705
1044
 
706
- log(`COLLAB R${round_number}: Starting work (role: ${my_role}, scope: ${JSON.stringify(my_scope)})`);
1045
+ const isCircling = mode === 'circling_strategy';
1046
+ const stepLabel = isCircling
1047
+ ? `${circling_phase === 'init' ? 'Init' : circling_phase === 'finalization' ? 'Final' : `SR${circling_subround}/S${circling_step}`}`
1048
+ : `R${round_number}`;
1049
+ log(`COLLAB ${stepLabel}: Starting work (role: ${my_role}, scope: ${JSON.stringify(my_scope)})`);
707
1050
 
708
- // Build round-specific prompt
709
- const prompt = buildCollabPrompt(task, round_number, shared_intel, my_scope, my_role);
1051
+ // Build prompt — circling uses directed inputs, other modes use shared intel
1052
+ const prompt = isCircling
1053
+ ? buildCirclingPrompt(task, roundData)
1054
+ : buildCollabPrompt(task, round_number, shared_intel, my_scope, my_role);
710
1055
 
711
- if (DRY_RUN) {
712
- log(`[DRY RUN] Collab prompt:\n${prompt}`);
713
- break;
714
- }
1056
+ if (DRY_RUN) {
1057
+ log(`[DRY RUN] Collab prompt:\n${prompt}`);
1058
+ break;
1059
+ }
715
1060
 
716
- // Execute Claude
717
- const llmResult = await runLLM(prompt, task, worktreePath);
718
- const output = llmResult.stdout || '';
1061
+ // Execute provider (LLM or shell)
1062
+ const llmResult = await runLLM(prompt, task, worktreePath);
1063
+ const output = llmResult.stdout || '';
1064
+
1065
+ // Parse reflection — circling uses delimiter-based parser, others use JSON-block parser
1066
+ let reflection;
1067
+ let circlingArtifacts = [];
1068
+
1069
+ if (llmResult.provider === 'shell') {
1070
+ reflection = {
1071
+ summary: output.trim().slice(-500) || '(no output)',
1072
+ learnings: '',
1073
+ confidence: llmResult.exitCode === 0 ? 1.0 : 0.0,
1074
+ vote: llmResult.exitCode === 0 ? 'converged' : 'continue',
1075
+ parse_failed: false,
1076
+ };
1077
+ } else if (isCircling) {
1078
+ const circResult = parseCirclingReflection(output);
1079
+ reflection = {
1080
+ summary: circResult.summary,
1081
+ learnings: '',
1082
+ confidence: circResult.confidence,
1083
+ vote: circResult.vote,
1084
+ parse_failed: circResult.parse_failed,
1085
+ };
1086
+ circlingArtifacts = circResult.circling_artifacts;
1087
+ } else {
1088
+ reflection = parseReflection(output);
1089
+ }
719
1090
 
720
- // Parse reflection from output
721
- const reflection = parseReflection(output);
1091
+ // List modified files
1092
+ let artifacts = [];
1093
+ try {
1094
+ if (worktreePath) {
1095
+ const status = require('child_process').execSync('git status --porcelain', {
1096
+ cwd: worktreePath, timeout: 5000, encoding: 'utf-8',
1097
+ }).trim();
1098
+ artifacts = status.split('\n').filter(Boolean).map(line => line.slice(3));
1099
+ }
1100
+ } catch { /* best effort */ }
722
1101
 
723
- // List modified files
724
- let artifacts = [];
725
- try {
726
- if (worktreePath) {
727
- const status = require('child_process').execSync('git status --porcelain', {
728
- cwd: worktreePath, timeout: 5000, encoding: 'utf-8',
729
- }).trim();
730
- artifacts = status.split('\n').filter(Boolean).map(line => line.slice(3));
1102
+ // Submit reflection
1103
+ try {
1104
+ await natsRequest('mesh.collab.reflect', {
1105
+ session_id: sessionId,
1106
+ node_id: NODE_ID,
1107
+ round: round_number,
1108
+ summary: reflection.summary,
1109
+ learnings: reflection.learnings || '',
1110
+ artifacts,
1111
+ confidence: reflection.confidence,
1112
+ vote: reflection.vote,
1113
+ parse_failed: reflection.parse_failed,
1114
+ // Circling extensions
1115
+ circling_step: isCircling ? circling_step : null,
1116
+ circling_artifacts: circlingArtifacts,
1117
+ });
1118
+ const parseTag = reflection.parse_failed ? ' [PARSE FAILED]' : '';
1119
+ const artCount = circlingArtifacts.length > 0 ? `, ${circlingArtifacts.length} artifact(s)` : '';
1120
+ log(`COLLAB ${stepLabel}: Reflection submitted (vote: ${reflection.vote}, conf: ${reflection.confidence}${parseTag}${artCount})`);
1121
+ } catch (err) {
1122
+ log(`COLLAB ${stepLabel}: Reflection submit failed: ${err.message}`);
731
1123
  }
732
- } catch { /* best effort */ }
733
1124
 
734
- // Submit reflection
735
- try {
736
- await natsRequest('mesh.collab.reflect', {
737
- session_id: sessionId,
738
- node_id: NODE_ID,
739
- round: round_number,
740
- summary: reflection.summary,
741
- learnings: reflection.learnings,
742
- artifacts,
743
- confidence: reflection.confidence,
744
- vote: reflection.vote,
745
- parse_failed: reflection.parse_failed,
746
- });
747
- const parseTag = reflection.parse_failed ? ' [PARSE FAILED]' : '';
748
- log(`COLLAB R${round_number}: Reflection submitted (vote: ${reflection.vote}, conf: ${reflection.confidence}${parseTag})`);
749
- } catch (err) {
750
- log(`COLLAB R${round_number}: Reflection submit failed: ${err.message}`);
1125
+ // Check if session is done (converged/completed/aborted)
1126
+ try {
1127
+ const status = await natsRequest('mesh.collab.status', { session_id: sessionId });
1128
+ if (['converged', 'completed', 'aborted'].includes(status.status)) {
1129
+ log(`COLLAB: Session ${sessionId} is ${status.status}. Done.`);
1130
+ lastKnownSessionStatus = status.status;
1131
+ roundsDone = true;
1132
+ }
1133
+ } catch { /* continue listening */ }
751
1134
  }
752
-
753
- // Check if session is done (converged/completed/aborted)
754
- try {
755
- const status = await natsRequest('mesh.collab.status', { session_id: sessionId });
756
- if (['converged', 'completed', 'aborted'].includes(status.status)) {
757
- log(`COLLAB: Session ${sessionId} is ${status.status}. Done.`);
758
- roundsDone = true;
759
- }
760
- } catch { /* continue listening */ }
1135
+ } finally {
1136
+ clearInterval(sessionHeartbeat);
1137
+ roundSub.unsubscribe();
761
1138
  }
762
1139
 
763
- roundSub.unsubscribe();
764
-
765
- // Commit and merge worktree
766
- const mergeResult = commitAndMergeWorktree(worktreePath, `${task.task_id}-${NODE_ID}`, `collab contribution from ${NODE_ID}`);
767
- cleanupWorktree(worktreePath, mergeResult && !mergeResult?.merged);
1140
+ // Commit and merge only on successful convergence — don't merge partial
1141
+ // work from aborted/failed sessions into main.
1142
+ // Uses lastKnownSessionStatus (set during round loop or heartbeat) instead of
1143
+ // a fresh network read, which could see stale state due to NATS latency.
1144
+ try {
1145
+ if (['completed', 'converged'].includes(lastKnownSessionStatus)) {
1146
+ const mergeResult = commitAndMergeWorktree(worktreePath, `${task.task_id}-${NODE_ID}`, `collab contribution from ${NODE_ID}`);
1147
+ cleanupWorktree(worktreePath, mergeResult && !mergeResult?.merged);
1148
+ } else {
1149
+ log(`COLLAB: Session ${sessionId} ended as ${lastKnownSessionStatus || 'unknown'} — discarding worktree`);
1150
+ cleanupWorktree(worktreePath, false);
1151
+ }
1152
+ } catch (err) {
1153
+ log(`COLLAB WORKTREE CLEANUP FAILED: ${err.message}`);
1154
+ }
768
1155
 
769
1156
  writeAgentState('idle', null);
770
1157
  log(`COLLAB DONE: ${task.task_id} (node: ${NODE_ID})`);
@@ -839,6 +1226,48 @@ async function executeTask(task) {
839
1226
  continue;
840
1227
  }
841
1228
 
1229
+ // ── Mesh Harness: Mechanical Enforcement (LLM-agnostic) ──
1230
+ // Runs AFTER LLM exits successfully, BEFORE commit. This is the hard
1231
+ // enforcement layer — it doesn't depend on the LLM obeying prompt rules.
1232
+ const harnessResult = runMeshHarness({
1233
+ rules: getHarnessRules(),
1234
+ worktreePath,
1235
+ taskScope: task.scope,
1236
+ llmOutput: llmResult.stdout,
1237
+ hasMetric: !!task.metric,
1238
+ log,
1239
+ role: getRole(task.role),
1240
+ });
1241
+
1242
+ if (!harnessResult.pass) {
1243
+ log(`HARNESS BLOCKED: ${harnessResult.violations.length} violation(s)`);
1244
+ for (const v of harnessResult.violations) {
1245
+ log(` - [${v.rule}] ${v.message}`);
1246
+ }
1247
+ // Scope violations were already reverted by enforceScopeCheck.
1248
+ // Secret violations block the commit entirely.
1249
+ const hasSecrets = harnessResult.violations.some(v => v.rule === 'no-hardcoded-secrets');
1250
+ if (hasSecrets) {
1251
+ const attemptRecord = {
1252
+ approach: `Attempt ${attempt}: harness blocked — secrets detected`,
1253
+ result: harnessResult.violations.map(v => v.message).join('; '),
1254
+ keep: false,
1255
+ };
1256
+ attempts.push(attemptRecord);
1257
+ await natsRequest('mesh.tasks.attempt', { task_id: task.task_id, ...attemptRecord });
1258
+ log(`Attempt ${attempt}: harness blocked commit (secrets). Retrying.`);
1259
+ continue;
1260
+ }
1261
+ // Non-secret violations (scope reverts, output blocks): proceed with warnings
1262
+ }
1263
+
1264
+ if (harnessResult.warnings.length > 0) {
1265
+ log(`HARNESS WARNINGS: ${harnessResult.warnings.length}`);
1266
+ for (const w of harnessResult.warnings) {
1267
+ log(` - [${w.rule}] ${w.message}`);
1268
+ }
1269
+ }
1270
+
842
1271
  // If no metric, trust LLM output and complete
843
1272
  if (!task.metric) {
844
1273
  const attemptRecord = {
@@ -853,11 +1282,20 @@ async function executeTask(task) {
853
1282
  const mergeResult = commitAndMergeWorktree(worktreePath, task.task_id, summary);
854
1283
  const keepBranch = mergeResult && !mergeResult.merged; // keep on merge conflict
855
1284
 
1285
+ // Post-commit validation (conventional commits, etc.)
1286
+ if (mergeResult?.committed) {
1287
+ runPostCommitValidation(getHarnessRules(), worktreePath, log);
1288
+ }
1289
+
856
1290
  await natsRequest('mesh.tasks.complete', {
857
1291
  task_id: task.task_id,
858
1292
  result: {
859
1293
  success: true, summary, artifacts: [],
860
1294
  cost: sessionInfo?.cost || null,
1295
+ harness: {
1296
+ violations: harnessResult.violations,
1297
+ warnings: harnessResult.warnings,
1298
+ },
861
1299
  sha: mergeResult?.sha || null,
862
1300
  merged: mergeResult?.merged ?? null,
863
1301
  },
@@ -885,6 +1323,11 @@ async function executeTask(task) {
885
1323
  const mergeResult = commitAndMergeWorktree(worktreePath, task.task_id, summary);
886
1324
  const keepBranch = mergeResult && !mergeResult.merged;
887
1325
 
1326
+ // Post-commit validation (conventional commits, etc.)
1327
+ if (mergeResult?.committed) {
1328
+ runPostCommitValidation(getHarnessRules(), worktreePath, log);
1329
+ }
1330
+
888
1331
  await natsRequest('mesh.tasks.complete', {
889
1332
  task_id: task.task_id,
890
1333
  result: {
@@ -894,6 +1337,10 @@ async function executeTask(task) {
894
1337
  cost: sessionInfo?.cost || null,
895
1338
  sha: mergeResult?.sha || null,
896
1339
  merged: mergeResult?.merged ?? null,
1340
+ harness: {
1341
+ violations: harnessResult.violations,
1342
+ warnings: harnessResult.warnings,
1343
+ },
897
1344
  },
898
1345
  });
899
1346
  cleanupWorktree(worktreePath, keepBranch);
@@ -986,8 +1433,82 @@ async function main() {
986
1433
  })();
987
1434
  log(` Listening: mesh.agent.${NODE_ID}.alive`);
988
1435
 
1436
+ // Subscribe to collab recruit broadcasts — allows this node to join
1437
+ // collab sessions without being the claiming node
1438
+ const recruitSub = nc.subscribe('mesh.collab.*.recruit');
1439
+ (async () => {
1440
+ for await (const msg of recruitSub) {
1441
+ try {
1442
+ const recruit = JSON.parse(sc.decode(msg.data));
1443
+ if (currentTaskId) continue; // busy
1444
+
1445
+ // Fetch task to check preferences and get collab spec
1446
+ const task = await natsRequest('mesh.tasks.get', { task_id: recruit.task_id }, 5000);
1447
+ if (!task || !task.collaboration) continue;
1448
+ if (task.owner === NODE_ID) continue; // we claimed it, already handling
1449
+
1450
+ // Check preferred_nodes
1451
+ if (task.preferred_nodes && task.preferred_nodes.length > 0) {
1452
+ if (!task.preferred_nodes.includes(NODE_ID)) continue;
1453
+ }
1454
+ // Check exclude_nodes
1455
+ if (task.exclude_nodes && task.exclude_nodes.length > 0) {
1456
+ if (task.exclude_nodes.includes(NODE_ID)) continue;
1457
+ }
1458
+
1459
+ log(`RECRUIT: Joining collab session ${recruit.session_id} for task ${recruit.task_id}`);
1460
+ currentTaskId = task.task_id;
1461
+ await executeCollabTask(task);
1462
+ currentTaskId = null;
1463
+ } catch (err) {
1464
+ log(`RECRUIT ERROR: ${err.message}`);
1465
+ currentTaskId = null;
1466
+ }
1467
+ }
1468
+ })();
1469
+ log(` Listening: mesh.collab.*.recruit (collab recruiting)`);
1470
+
1471
+ // Also poll for recruiting sessions on idle — catches recruits we missed
1472
+ async function checkRecruitingSessions() {
1473
+ if (currentTaskId) return; // busy
1474
+ try {
1475
+ const sessions = await natsRequest('mesh.collab.recruiting', {}, 5000);
1476
+ if (!sessions || !Array.isArray(sessions) || sessions.length === 0) return;
1477
+
1478
+ for (const s of sessions) {
1479
+ if (currentTaskId) break; // became busy
1480
+ // Skip if we already joined this session
1481
+ if (s.node_ids && s.node_ids.includes(NODE_ID)) continue;
1482
+ // Skip if session is full
1483
+ if (s.max_nodes && s.current_nodes >= s.max_nodes) continue;
1484
+
1485
+ // Fetch task to check preferences
1486
+ const task = await natsRequest('mesh.tasks.get', { task_id: s.task_id }, 5000);
1487
+ if (!task || !task.collaboration) continue;
1488
+ if (task.preferred_nodes && task.preferred_nodes.length > 0) {
1489
+ if (!task.preferred_nodes.includes(NODE_ID)) continue;
1490
+ }
1491
+ if (task.exclude_nodes && task.exclude_nodes.length > 0) {
1492
+ if (task.exclude_nodes.includes(NODE_ID)) continue;
1493
+ }
1494
+
1495
+ log(`RECRUIT POLL: Joining collab session ${s.session_id} for task ${s.task_id}`);
1496
+ currentTaskId = task.task_id;
1497
+ await executeCollabTask(task);
1498
+ currentTaskId = null;
1499
+ }
1500
+ } catch { /* silent — recruiting poll is best-effort */ }
1501
+ }
1502
+
989
1503
  while (running) {
990
1504
  try {
1505
+ // Check for recruiting collab sessions before trying to claim
1506
+ await checkRecruitingSessions();
1507
+ if (currentTaskId) {
1508
+ await new Promise(r => setTimeout(r, POLL_INTERVAL));
1509
+ continue;
1510
+ }
1511
+
991
1512
  // Claim next available task (longer timeout — KV operations on remote NATS can be slow)
992
1513
  const task = await natsRequest('mesh.tasks.claim', { node_id: NODE_ID }, 60000);
993
1514
 
@@ -1035,3 +1556,4 @@ main().catch(err => {
1035
1556
  console.error(`[mesh-agent] Fatal: ${err.message}`);
1036
1557
  process.exit(1);
1037
1558
  });
1559
+ // deploy-v7f0130b